Software vulnerability automation modal (#3920)

This commit is contained in:
RachelElysia
2022-02-04 18:48:35 -06:00
committed by GitHub
parent 14d36d8e4d
commit 6e5aee3cdf
19 changed files with 840 additions and 276 deletions
@@ -0,0 +1 @@
* Ability in UI to set a software vulnerability automation on Software page
+79 -72
View File
@@ -14,74 +14,86 @@ describe(
cy.seedPolicies();
cy.addDockerHost();
});
after(() => {
cy.logout();
cy.stopDockerHost();
});
describe("Mange hosts tests", () => {
describe("Dashboard and navigation", () => {
beforeEach(() => {
cy.loginWithCySession("anna@organization.com", "user123#");
cy.visit("/dashboard");
});
it("displays intended admin dashboard", () => {
cy.getAttached(".homepage__wrapper").within(() => {
cy.findByText(/fleet test/i).should("exist");
cy.getAttached(".hosts-summary").should("exist");
cy.getAttached(".hosts-status").should("exist");
cy.getAttached(".home-software").should("exist");
cy.getAttached(".activity-feed").should("exist");
});
});
it("displays intended admin top navigation", () => {
cy.getAttached(".site-nav-container").within(() => {
cy.findByText(/hosts/i).should("exist");
cy.findByText(/software/i).should("exist");
cy.findByText(/queries/i).should("exist");
cy.findByText(/schedule/i).should("exist");
cy.findByText(/policies/i).should("exist");
cy.getAttached(".user-menu").click();
cy.findByText(/settings/i).click();
});
cy.getAttached(".react-tabs__tab--selected").within(() => {
cy.findByText(/organization/i).should("exist");
});
cy.getAttached(".site-nav-container").within(() => {
cy.getAttached(".user-menu").click();
cy.findByText(/manage users/i).click();
});
cy.getAttached(".react-tabs__tab--selected").within(() => {
cy.findByText(/users/i).should("exist");
});
});
});
describe("Manage hosts page", () => {
beforeEach(() => {
cy.loginWithCySession("anna@organization.com", "user123#");
cy.visit("/hosts/manage");
});
it("Can verify user is on the Manage Hosts page", () => {
cy.getAttached(".manage-hosts").within(() => {
cy.findByText(/edit columns/i).should("exist");
});
});
it("Can see correct global navigation items", () => {
cy.getAttached("nav").within(() => {
cy.findByText(/hosts/i).should("exist");
cy.findByText(/queries/i).should("exist");
cy.findByText(/schedule/i).should("exist");
cy.findByText(/settings/i).should("exist");
});
});
it("Can verify teams is disabled", () => {
it("verifies teams is disabled on Manage Host page", () => {
cy.contains(/team/i).should("not.exist");
});
it("Can see and click the 'Generate installer' button", () => {
it("allows admin to see and click the 'Generate installer' button", () => {
cy.findByRole("button", { name: /generate installer/i }).click();
cy.contains(/team/i).should("not.exist");
cy.contains("button", /done/i).click();
});
it("Can manage and add enroll secret", () => {
it("allows admin to manage and add enroll secret", () => {
cy.contains("button", /manage enroll secret/i).click();
cy.contains("button", /add secret/i).click();
cy.contains("button", /save/i).click();
cy.contains("button", /done/i).click();
});
it("Can open the 'Add label' form", () => {
it("allows admin to open the 'Add label' form", () => {
cy.findByRole("button", { name: /add label/i }).click();
cy.findByRole("button", { name: /cancel/i }).click();
});
});
describe("Host details tests", () => {
beforeEach(() => {
cy.loginWithCySession("anna@organization.com", "user123#");
cy.visit("/hosts/1");
});
it("Can verify teams is disabled", () => {
it("verifies teams is disabled on Host Details page", () => {
cy.findByText(/team/i).should("not.exist");
cy.contains("button", /transfer/i).should("not.exist");
});
it("Can delete a query", () => {
it("allows admin to delete a query", () => {
cy.findByRole("button", { name: /delete/i }).click();
cy.findByText(/delete host/i).should("exist");
cy.findByRole("button", { name: /cancel/i }).click();
});
it("Can create a new query", () => {
it("allows admin to create a new query", () => {
cy.findByRole("button", { name: /query/i }).click();
cy.findByRole("button", { name: /create custom query/i }).should(
"exist"
@@ -91,18 +103,31 @@ describe(
});
});
});
describe("Queries tests", () => {
describe("Manage software page", () => {
beforeEach(() => {
cy.loginWithCySession("anna@organization.com", "user123#");
cy.visit("/software/manage");
});
it("allows global admin to click 'Manage automations' button", () => {
cy.getAttached(".manage-software-page__header-wrap").within(() => {
cy.findByRole("button", { name: /manage automations/i }).click();
});
cy.getAttached(".manage-automations-modal__button-wrap").within(() => {
cy.findByRole("button", {
name: /cancel/i,
}).click();
});
});
});
describe("Query pages", () => {
beforeEach(() => {
cy.loginWithCySession("anna@organization.com", "user123#");
cy.visit("/queries/manage");
});
it("Can see the 'Observer can run' column on the queries table", () => {
it("displays the 'Observer can run' column on the queries table", () => {
cy.contains(/observer can run/i);
});
it("Can add a new query", () => {
it("allows admin add a new query", () => {
cy.findByRole("button", { name: /new query/i }).click();
cy.getAttached(".ace_text-input")
.click({ force: true })
@@ -127,8 +152,7 @@ describe(
});
cy.findByText(/query created/i).should("exist");
});
it("Can edit a query", () => {
it("allows admin to edit a query", () => {
cy.findByText(/cypress test query/i).click({ force: true });
cy.getAttached(".ace_text-input")
.click({ force: true })
@@ -139,8 +163,7 @@ describe(
cy.findByText("Save").click(); // we have 'save as new' also
cy.findByText(/query updated/i).should("exist");
});
it("Can run a query", () => {
it("allows admin to run a query", () => {
cy.findByText(/cypress test query/i).click({ force: true });
cy.findByText(/run query/i).click({ force: true });
cy.findByText(/select targets/i).should("exist");
@@ -150,26 +173,22 @@ describe(
cy.findByText(/querying selected hosts/i).should("exist"); // target count
});
});
describe("Policies tests", () => {
describe("Manage policies page", () => {
beforeEach(() => {
cy.loginWithCySession("anna@organization.com", "user123#");
cy.visit("/policies/manage");
});
it("Can manage automations", () => {
it("allows admin to click 'Manage automations' button", () => {
cy.findByRole("button", { name: /manage automations/i }).click();
cy.findByRole("button", { name: /cancel/i }).click();
});
it("Can add a policy", () => {
it("allows admin to add a new policy", () => {
cy.findByRole("button", { name: /add a policy/i }).click();
cy.getAttached(".modal__ex").within(() => {
cy.findByRole("button").click();
});
});
it("Can delete a policy", () => {
it("allows admin to delete a policy", () => {
// select checkmark on table
cy.getAttached("tbody").within(() => {
cy.getAttached("tr")
@@ -178,15 +197,13 @@ describe(
cy.getAttached(".fleet-checkbox__input").check({ force: true });
});
});
cy.findByRole("button", { name: /delete/i }).click();
cy.getAttached(".remove-policies-modal").within(() => {
cy.findByRole("button", { name: /delete/i }).should("exist");
cy.findByRole("button", { name: /cancel/i }).click();
});
});
it("Can select a policy and verify user can run and save", () => {
it("allows admin to select a policy and see CTAs to run and save", () => {
cy.getAttached(".data-table__table").within(() => {
cy.findByRole("button", { name: /filevault enabled/i }).click();
});
@@ -196,58 +213,48 @@ describe(
});
});
});
describe("Settings tests", () => {
describe("Admin settings page", () => {
// cypress tends to fail on uncaught exceptions. since we have
// our own error handling, it's suggested to use this block to
// suppress so the tests will keep running
Cypress.on("uncaught:exception", () => {
return false;
});
beforeEach(() => {
cy.loginWithCySession("anna@organization.com", "user123#");
cy.visit("/settings/users");
});
it("Can verify teams is disabled on the Settings page", () => {
it("hides access team settings", () => {
cy.findByText(/teams/i).should("not.exist");
});
it("Can verify all other suboptions exist", () => {
it("allows admin to access other settings", () => {
cy.getAttached(".react-tabs").within(() => {
cy.findByText(/organization settings/i).should("exist");
cy.findByText(/users/i).click();
});
});
it("Can see and click the 'Create user' button", () => {
it("displays the 'Create user' button", () => {
cy.findByRole("button", { name: /create user/i }).click();
});
it("Can verify teams is disabled for creating a user", () => {
it("hides assigning a user to a team", () => {
cy.findByText(/team/i).should("not.exist");
});
it("Can verify user is not autorized to use the Team Settings page", () => {
it("verifies admin is not authorized to reach the Team Settings page", () => {
cy.visit("/settings/teams");
cy.findByText(/you do not have permissions/i).should("exist");
});
});
describe("Profile tests", () => {
describe("User profile page", () => {
beforeEach(() => {
cy.loginWithCySession("anna@organization.com", "user123#");
cy.visit("/profile");
});
it("Can verify teams is disabled for the Profile page", () => {
it("verifies teams is disabled for the Profile page", () => {
cy.getAttached(".user-settings__additional").within(() => {
cy.findByText(/teams/i).should("not.exist");
});
});
it("Can verify the role of the user is admin", () => {
it("renders elements according to role-based access controls", () => {
cy.getAttached(".user-settings__additional").within(() => {
cy.findByText("Role").next().contains(/admin/i);
});
+89 -59
View File
@@ -14,63 +14,80 @@ describe(
cy.seedPolicies();
cy.addDockerHost();
});
after(() => {
cy.logout();
cy.stopDockerHost();
});
describe("Mange hosts tests", () => {
describe("Dashboard and navigation", () => {
beforeEach(() => {
cy.loginWithCySession("mary@organization.com", "user123#");
cy.visit("/dashboard");
});
it("displays intended global maintainer dashboard", () => {
cy.getAttached(".homepage__wrapper").within(() => {
cy.findByText(/fleet test/i).should("exist");
cy.getAttached(".hosts-summary").should("exist");
cy.getAttached(".hosts-status").should("exist");
cy.getAttached(".home-software").should("exist");
cy.getAttached(".activity-feed").should("exist");
});
});
it("displays intended global maintainer top navigation", () => {
cy.getAttached(".site-nav-container").within(() => {
cy.findByText(/hosts/i).should("exist");
cy.findByText(/software/i).should("exist");
cy.findByText(/queries/i).should("exist");
cy.findByText(/schedule/i).should("exist");
cy.findByText(/policies/i).should("exist");
cy.getAttached(".user-menu").click();
cy.findByText(/settings/i).should("not.exist");
cy.findByText(/manage users/i).should("not.exist");
});
});
});
describe("Manage hosts page", () => {
beforeEach(() => {
cy.loginWithCySession("mary@organization.com", "user123#");
cy.visit("/hosts/manage");
});
it("Can verify user is on the Manage Hosts page", () => {
it("verifies maintainer is on the Manage Hosts page", () => {
cy.getAttached(".manage-hosts").within(() => {
cy.findByText(/edit columns/i).should("exist");
});
});
it("Can verify teams is disabled", () => {
it("verifies teams is disabled", () => {
cy.contains(/team/i).should("not.exist");
});
it("Can see and click the 'Generate installer' button", () => {
it("allows maintainer to see and click the 'Generate installer' button", () => {
cy.findByRole("button", { name: /generate installer/i }).click();
cy.contains(/team/i).should("not.exist");
cy.contains("button", /done/i).click();
});
it("Can manage the enroll secret", () => {
it("allows maintainer to manage the enroll secret", () => {
cy.contains("button", /manage enroll secret/i).click();
cy.contains("button", /done/i).click();
});
it("Can open the 'Add label' form", () => {
it("allows maintainer to open the 'Add label' form", () => {
cy.findByRole("button", { name: /add label/i }).click();
cy.findByRole("button", { name: /cancel/i }).click();
});
});
describe("Host details tests", () => {
beforeEach(() => {
cy.loginWithCySession("mary@organization.com", "user123#");
cy.visit("/hosts/1");
});
it("Can verify teams is disabled", () => {
it("verifies teams is disabled", () => {
cy.findByText(/team/i).should("not.exist");
cy.contains("button", /transfer/i).should("not.exist");
});
it("Can delete a query", () => {
it("allows maintainer to delete a host", () => {
cy.findByRole("button", { name: /delete/i }).click();
cy.findByText(/delete host/i).should("exist");
cy.findByRole("button", { name: /cancel/i }).click();
});
it("Can create a new query", () => {
it("allows maintainer to create a new query on a host", () => {
cy.findByRole("button", { name: /query/i }).click();
cy.findByRole("button", { name: /create custom query/i }).should(
"exist"
@@ -80,18 +97,45 @@ describe(
});
});
});
describe("Queries tests", () => {
describe("Manage software page", () => {
beforeEach(() => {
cy.loginWithCySession("mary@organization.com", "user123#");
cy.visit("/software/manage");
});
it("allows maintainer to click 'Manage automations' button", () => {
it("manages software automations when all teams selected", () => {
cy.getAttached(".manage-software-page__header-wrap").within(() => {
cy.getAttached(".Select").within(() => {
cy.findByText(/all teams/i).should("exist");
});
cy.findByRole("button", { name: /manage automations/i }).click();
cy.findByRole("button", { name: /cancel/i }).click();
});
});
it("hides manage automations button when all teams not selected", () => {
cy.getAttached(".manage-software-page__header-wrap").within(() => {
cy.getAttached(".Select").within(() => {
cy.getAttached(".Select-control").click();
cy.getAttached(".Select-menu-outer").within(() => {
cy.findByText(/apples/i).should("exist");
});
cy.findByRole("button", {
name: /manage automations/i,
}).should("not.exist");
});
});
});
});
});
describe("Query pages", () => {
beforeEach(() => {
cy.loginWithCySession("mary@organization.com", "user123#");
cy.visit("/queries/manage");
});
it("Can see the 'Observer can run' column on the queries table", () => {
it("displays the 'Observer can run' column on the queries table", () => {
cy.contains(/observer can run/i);
});
it("Can add a new query", () => {
it("allows maintainer to add a new query", () => {
cy.findByRole("button", { name: /new query/i }).click();
cy.getAttached(".ace_text-input")
.click({ force: true })
@@ -108,7 +152,9 @@ describe(
cy.findByLabelText(/description/i)
.click()
.type("Cypress test of create new query flow.");
cy.findByLabelText(/observers can run/i).click({ force: true });
cy.findByLabelText(/observers can run/i).click({
force: true,
});
cy.findByRole("button", { name: /save query/i }).click();
});
});
@@ -116,8 +162,7 @@ describe(
});
cy.findByText(/query created/i).should("exist");
});
it("Can edit a query", () => {
it("allows maintainer to edit a query", () => {
cy.findByText(/cypress test query/i).click({ force: true });
cy.getAttached(".ace_text-input")
.click({ force: true })
@@ -128,8 +173,7 @@ describe(
cy.findByText("Save").click(); // we have 'save as new' also
cy.findByText(/query updated/i).should("exist");
});
it("Can run a query", () => {
it("allows maintainer to run a query", () => {
cy.findByText(/cypress test query/i).click({ force: true });
cy.findByText(/run query/i).click({ force: true });
cy.findByText(/select targets/i).should("exist");
@@ -139,26 +183,22 @@ describe(
cy.findByText(/querying selected hosts/i).should("exist"); // target count
});
});
describe("Policies tests", () => {
describe("Manage policies page", () => {
beforeEach(() => {
cy.loginWithCySession("mary@organization.com", "user123#");
cy.visit("/policies/manage");
});
it("Can manage automations", () => {
it("allows maintainer to manage automations", () => {
cy.findByRole("button", { name: /manage automations/i }).click();
cy.findByRole("button", { name: /cancel/i }).click();
});
it("Can add a policy", () => {
it("allows maintainer to add a policy", () => {
cy.findByRole("button", { name: /add a policy/i }).click();
cy.getAttached(".modal__ex").within(() => {
cy.findByRole("button").click();
});
});
it("Can delete a policy", () => {
it("allows maintainer to delete a policy", () => {
// select checkmark on table
cy.getAttached("tbody").within(() => {
cy.getAttached("tr")
@@ -167,21 +207,21 @@ describe(
cy.getAttached(".fleet-checkbox__input").check({ force: true });
});
});
cy.findByRole("button", { name: /delete/i }).click();
cy.getAttached(".remove-policies-modal").within(() => {
cy.findByRole("button", { name: /delete/i }).should("exist");
cy.findByRole("button", { name: /cancel/i }).click();
});
});
it("Can select a policy and verify user can run and save", () => {
it("allows maintainer to select a policy and see CTAs to run and save", () => {
cy.getAttached(".data-table__table").within(() => {
cy.getAttached("tbody").within(() => {
cy.getAttached("tr")
.first()
.within(() => {
cy.findByRole("button", { name: /filevault enabled/i }).click();
cy.findByRole("button", {
name: /filevault enabled/i,
}).click();
});
});
});
@@ -191,14 +231,12 @@ describe(
});
});
});
describe("Packs tests", () => {
describe("Manage packs page", () => {
beforeEach(() => {
cy.loginWithCySession("mary@organization.com", "user123#");
cy.visit("/packs/manage");
});
it("Can create a pack", () => {
it("allows maintainer to create a pack", () => {
cy.findByRole("button", { name: /create new pack/i }).click();
cy.findByLabelText(/name/i).click().type("Errors and crashes");
cy.findByLabelText(/description/i)
@@ -206,8 +244,7 @@ describe(
.type("See all user errors and window crashes.");
cy.findByRole("button", { name: /save query pack/i }).click();
});
it("Can delete a pack", () => {
it("allows maintainer to delete a pack", () => {
// select checkmark on table
cy.getAttached("tbody").within(() => {
cy.getAttached("tr")
@@ -218,31 +255,26 @@ describe(
});
// cy.get(".fleet-checkbox__input").check({ force: true });
cy.findByRole("button", { name: /delete/i }).click();
// Can't figure out how attach findByRole onto modal button
// Can't use findByText because delete button under modal
cy.get(".remove-pack-modal__btn-wrap > .button--alert")
.contains("button", /delete/i)
.click();
cy.findByText(/successfully deleted/i).should("be.visible");
cy.findByText(/server errors/i).should("not.exist");
});
});
describe("Profile tests", () => {
describe("User profile page", () => {
beforeEach(() => {
cy.loginWithCySession("mary@organization.com", "user123#");
cy.visit("/profile");
});
it("Can verify teams is disabled for the Profile page", () => {
it("verifies teams is disabled for the Profile page", () => {
cy.getAttached(".user-settings__additional").within(() => {
cy.findByText(/teams/i).should("not.exist");
});
});
it("Can verify the role of the user is maintainer", () => {
it("renders elements according to role-based access controls", () => {
cy.getAttached(".user-settings__additional").within(() => {
cy.findByText("Role")
.next()
@@ -260,12 +292,10 @@ describe(
Cypress.on("uncaught:exception", () => {
return false;
});
beforeEach(() => {
cy.loginWithCySession("mary@organization.com", "user123#");
});
it("Can verify user does not have access to settings", () => {
it("verifies maintainer does not have access to settings", () => {
cy.findByText(/settings/i).should("not.exist");
cy.visit("/settings/organization");
cy.findByText(/you do not have permissions/i).should("exist");
+66 -69
View File
@@ -9,100 +9,103 @@ describe("Free tier - Observer user", () => {
cy.seedPolicies();
cy.addDockerHost();
});
after(() => {
cy.logout();
cy.stopDockerHost();
});
describe("Mange hosts tests", () => {
describe("Dashboard and navigation", () => {
beforeEach(() => {
cy.loginWithCySession("oliver@organization.com", "user123#");
cy.visit("/dashboard");
});
it("displays intended global observer dashboard", () => {
cy.getAttached(".homepage__wrapper").within(() => {
cy.findByText(/fleet test/i).should("exist");
cy.getAttached(".hosts-summary").should("exist");
cy.getAttached(".hosts-status").should("exist");
cy.getAttached(".home-software").should("exist");
cy.getAttached(".activity-feed").should("exist");
});
});
it("displays intended global observer top navigation", () => {
cy.getAttached(".site-nav-container").within(() => {
cy.findByText(/hosts/i).should("exist");
cy.findByText(/software/i).should("exist");
cy.findByText(/queries/i).should("exist");
cy.findByText(/schedule/i).should("not.exist");
cy.findByText(/policies/i).should("exist");
cy.getAttached(".user-menu").click();
cy.findByText(/settings/i).should("not.exist");
cy.findByText(/manage users/i).should("not.exist");
});
});
});
describe("Manage hosts page", () => {
beforeEach(() => {
cy.loginWithCySession("oliver@organization.com", "user123#");
cy.visit("/hosts/manage");
});
it("Can verify nav is restricted", () => {
// we expect a 402 error from the teams API
// in Cypress, we can't update the context for if we're
// in the premium tier, so the tests runs the teams API
Cypress.on("uncaught:exception", () => {
return false;
});
// Nav restrictions
cy.findByText(/settings/i).should("not.exist");
cy.findByText(/schedule/i).should("not.exist");
cy.visit("/settings/organization");
cy.findByText(/you do not have permissions/i).should("exist");
cy.visit("/packs/manage");
cy.findByText(/you do not have permissions/i).should("exist");
cy.visit("/schedule/manage");
cy.findByText(/you do not have permissions/i).should("exist");
});
it("Can verify teams is disabled", () => {
it("verifies teams is disabled on Manage Host page", () => {
cy.findByText(/teams/i).should("not.exist");
});
it("Can verify user cannot generate an installer", () => {
it("hides generate installer button", () => {
cy.contains("button", /generate installer/i).should("not.exist");
});
it("Can verify user cannot add a label", () => {
it("hides add a label button", () => {
cy.contains("button", /add label/i).should("not.exist");
});
it("Can verify user cannot manage the enroll secret", () => {
it("hides manage enroll secrets button", () => {
cy.contains("button", /manage enroll secret/i).should("not.exist");
});
});
describe("Host details tests", () => {
describe("Host details page", () => {
beforeEach(() => {
cy.loginWithCySession("oliver@organization.com", "user123#");
cy.visit("/hosts/1");
});
it("Can verify teams is disabled", () => {
it("verifies teams is disabled on Host Details page", () => {
cy.findByText(/team/i).should("not.exist");
});
it("Can verify user cannot transfer host", () => {
it("hides transfer host button", () => {
cy.contains("button", /transfer/i).should("not.exist");
});
it("Can verify user cannot delete host", () => {
it("hides delete host button", () => {
cy.contains("button", /delete/i).should("not.exist");
});
it("Can verify user cannot query host", () => {
it("hides query host button", () => {
cy.contains("button", /query/i).click();
});
it("Can verify user cannot create query", () => {
cy.contains("button", /create custom query/i).should("not.exist");
});
describe("Manage software page", () => {
beforeEach(() => {
cy.loginWithCySession("oliver@organization.com", "user123#");
cy.visit("/software/manage");
});
it("hides manage automations button", () => {
cy.getAttached(".manage-software-page__header-wrap").within(() => {
cy.findByRole("button", { name: /manage automations/i }).should(
"not.exist"
);
});
});
});
describe("Queries tests", () => {
describe("Query page", () => {
beforeEach(() => {
cy.loginWithCySession("oliver@organization.com", "user123#");
cy.visit("/queries/manage");
});
it("Can verify that user does not see 'Observer can run' column", () => {
it("hides 'Observer can run' column", () => {
cy.getAttached("thead").within(() => {
cy.findByText(/observer can run/i).should("not.exist");
});
});
it("Can verify that user cannot create a query", () => {
it("hides create a query button", () => {
cy.findByRole("button", { name: /create new query/i }).should(
"not.exist"
);
});
it("Can verify that user can select a query and only run it", () => {
it("verifies observer can select a query and only run it", () => {
cy.getAttached(".data-table__table").within(() => {
cy.findByRole("button", { name: /detect presence/i }).click();
});
@@ -115,53 +118,49 @@ describe("Free tier - Observer user", () => {
cy.findByRole("button", { name: /run query/i }).should("exist");
});
});
describe("Policies tests", () => {
describe("Manage policies page", () => {
beforeEach(() => {
cy.loginWithCySession("oliver@organization.com", "user123#");
cy.visit("/policies/manage");
});
it("Can verify user cannot manage automations", () => {
it("hides manage automations button", () => {
cy.findByRole("button", { name: /manage automations/i }).should(
"not.exist"
);
});
it("Can verify user cannot add a policy", () => {
it("hides add a policy button", () => {
cy.findByRole("button", { name: /add a policy/i }).should("not.exist");
});
it("Can verify user cannot run, edit, or delete a policy", () => {
it("hides run, edit, or delete a policy", () => {
cy.getAttached("tbody").within(() => {
cy.get("tr")
cy.getAttached("tr")
.first()
.within(() => {
cy.get(".fleet-checkbox__input").should("not.exist");
});
cy.findByText(/filevault enabled/i).click();
});
cy.getAttached(".data-table__table").within(() => {
cy.findByRole("button", {
name: /filevault enabled/i,
}).click();
});
cy.getAttached(".policy-form__wrapper").within(() => {
cy.findByRole("button", { name: /run/i }).should("not.exist");
cy.findByRole("button", { name: /save/i }).should("not.exist");
});
});
});
describe("Profile tests", () => {
describe("User profile page", () => {
beforeEach(() => {
cy.loginWithCySession("oliver@organization.com", "user123#");
cy.visit("/profile");
});
it("Can verify teams is disabled for the Profile page", () => {
it("verifies teams is disabled for the Profile page", () => {
cy.getAttached(".user-settings__additional").within(() => {
cy.findByText(/teams/i).should("not.exist");
});
});
it("Can verify the role of the user is observer", () => {
it("renders elements according to role-based access controls", () => {
cy.getAttached(".user-settings__additional").within(() => {
cy.findByText("Role")
.next()
@@ -179,11 +178,9 @@ describe("Free tier - Observer user", () => {
Cypress.on("uncaught:exception", () => {
return false;
});
beforeEach(() => {
cy.loginWithCySession("oliver@organization.com", "user123#");
});
it("should restrict navigation according to role-based access controls", () => {
cy.findByText(/settings/i).should("not.exist");
cy.findByText(/schedule/i).should("not.exist");
+7 -4
View File
@@ -120,14 +120,17 @@ describe("Premium tier - Admin user", () => {
});
describe("Manage software page", () => {
beforeEach(() => cy.visit("/software/manage"));
it("displays manage automations button when all teams selected", () => {
it("allows global admin to click 'Manage automations' button", () => {
cy.getAttached(".manage-software-page__header-wrap").within(() => {
cy.getAttached(".Select").within(() => {
cy.findByText(/all teams/i).should("exist");
});
cy.findByRole("button", { name: /manage automations/i }).should(
"exist"
);
cy.findByRole("button", { name: /manage automations/i }).click();
});
cy.getAttached(".manage-automations-modal__button-wrap").within(() => {
cy.findByRole("button", {
name: /cancel/i,
}).click();
});
});
it("hides manage automations button when all teams not selected", () => {
+19 -15
View File
@@ -112,23 +112,27 @@ describe("Premium tier - Maintainer user", () => {
});
describe("Manage software page", () => {
beforeEach(() => cy.visit("/software/manage"));
it("displays manage automations button", () => {
cy.getAttached(".manage-software-page__header-wrap").within(() => {
cy.findByRole("button", { name: /manage automations/i }).should(
"exist"
);
it("allows global maintainer to click 'Manage automations' button", () => {
it("manages software automations when all teams selected", () => {
cy.getAttached(".manage-software-page__header-wrap").within(() => {
cy.getAttached(".Select").within(() => {
cy.findByText(/all teams/i).should("exist");
});
cy.findByRole("button", { name: /manage automations/i }).click();
cy.findByRole("button", { name: /cancel/i }).click();
});
});
});
it("hides manage automations button when all teams not selected", () => {
cy.getAttached(".manage-software-page__header-wrap").within(() => {
cy.getAttached(".Select").within(() => {
cy.getAttached(".Select-control").click();
cy.getAttached(".Select-menu-outer").within(() => {
cy.findByText(/apples/i).should("exist");
it("hides manage automations button when all teams not selected", () => {
cy.getAttached(".manage-software-page__header-wrap").within(() => {
cy.getAttached(".Select").within(() => {
cy.getAttached(".Select-control").click();
cy.getAttached(".Select-menu-outer").within(() => {
cy.findByText(/apples/i).should("exist");
});
cy.findByRole("button", {
name: /manage automations/i,
}).should("not.exist");
});
cy.findByRole("button", {
name: /manage automations/i,
}).should("not.exist");
});
});
});
+5 -1
View File
@@ -199,9 +199,13 @@ describe("Premium tier - Observer user", () => {
.first()
.within(() => {
cy.contains(".fleet-checkbox__input").should("not.exist");
cy.findByText(/filevault enabled/i).click();
});
});
cy.getAttached(".data-table__table").within(() => {
cy.findByRole("button", {
name: /filevault enabled/i,
}).click();
});
cy.getAttached(".policy-form__wrapper").within(() => {
cy.findByRole("button", { name: /run/i }).should("not.exist");
cy.findByRole("button", { name: /save/i }).should("not.exist");
+7 -7
View File
@@ -1,6 +1,10 @@
/* Config interface is a flattened version of the fleet/config API response */
import { IWebhookFailingPolicies } from "interfaces/webhook";
import {
IWebhookHostStatus,
IWebhookFailingPolicies,
IWebhookSoftwareVulnerabilities,
} from "interfaces/webhook";
import PropTypes from "prop-types";
export default PropTypes.shape({
@@ -226,13 +230,9 @@ export interface IConfigNested {
databases_path: string;
};
webhook_settings: {
host_status_webhook: {
enable_host_status_webhook: boolean;
destination_url: string;
host_percentage: number;
days_count: number;
};
host_status_webhook: IWebhookHostStatus;
failing_policies_webhook: IWebhookFailingPolicies;
vulnerabilities_webhook: IWebhookSoftwareVulnerabilities;
};
logging: {
debug: boolean;
+12
View File
@@ -7,9 +7,21 @@ export default PropTypes.shape({
host_batch_size: PropTypes.number,
});
export interface IWebhookHostStatus {
enable_host_status_webhook?: boolean;
destination_url?: string;
host_percentage?: number;
days_count?: number;
}
export interface IWebhookFailingPolicies {
destination_url?: string;
policy_ids?: number[];
enable_failing_policies_webhook?: boolean;
host_batch_size?: number;
}
export interface IWebhookSoftwareVulnerabilities {
destination_url?: string;
enable_vulnerabilities_webhook?: boolean;
host_batch_size?: number;
}
@@ -26,7 +26,7 @@ export const baseClass = "app-settings";
const AppSettingsPage = (): JSX.Element => {
const dispatch = useDispatch();
const { config, setConfig } = useContext(AppContext);
const { setConfig } = useContext(AppContext);
const {
data: appConfig,
@@ -422,7 +422,7 @@ const ManagePolicyPage = ({
{!!teamId && teamPoliciesError && <TableDataError />}
{!!teamId &&
!teamPoliciesError &&
(isLoadingTeamPolicies ? (
(isLoadingTeamPolicies && isLoadingFailingPoliciesWebhook ? (
<Spinner />
) : (
<PoliciesListWrapper
@@ -19,10 +19,6 @@
text-decoration: none;
}
&__info-header {
font-weight: $bold;
}
&__btn-wrap {
display: flex;
flex-direction: row-reverse;
@@ -32,42 +28,6 @@
margin-left: 12px;
}
&__advanced-options-button {
margin: $pad-medium 0;
color: $core-vibrant-blue;
font-weight: $bold;
font-size: $x-small;
}
.downcarat {
&::after {
content: url("../assets/images/icon-chevron-blue-16x16@2x.png");
transform: scale(0.5);
width: 16px;
border-radius: 0px;
padding: 0px;
padding-left: 2px;
margin-bottom: 2px;
}
}
.upcarat {
&::after {
content: url("../assets/images/icon-chevron-blue-16x16@2x.png");
transform: scale(0.5) rotate(180deg);
width: 16px;
border-radius: 0px;
padding: 0px;
padding-left: 2px;
margin-bottom: 4px;
margin-left: 14px;
}
}
.Select-value-label {
font-size: $small;
}
.tooltip__tooltip-icon {
img {
width: 16px;
@@ -1,11 +1,20 @@
import React, { useCallback, useContext, useEffect, useState } from "react";
import { useQuery } from "react-query";
import { useDispatch } from "react-redux";
import { InjectedRouter } from "react-router/lib/Router";
import ReactTooltip from "react-tooltip";
import { useDebouncedCallback } from "use-debounce/lib";
import formatDistanceToNowStrict from "date-fns/formatDistanceToNowStrict";
import { AppContext } from "context/app";
import { IConfig, IConfigNested } from "interfaces/config";
import { IWebhookSoftwareVulnerabilities } from "interfaces/webhook";
// @ts-ignore
import { getConfig } from "redux/nodes/app/actions";
// @ts-ignore
import { renderFlash } from "redux/nodes/notifications/actions";
import configAPI from "services/entities/config";
import usersAPI, { IGetMeResponse } from "services/entities/users";
import softwareAPI, {
ISoftwareResponse,
ISoftwareCountResponse,
@@ -29,6 +38,7 @@ import ExternalLinkIcon from "../../../../assets/images/open-new-tab-12x12@2x.pn
import QuestionIcon from "../../../../assets/images/icon-question-16x16@2x.png";
import generateTableHeaders from "./SoftwareTableConfig";
import ManageAutomationsModal from "./components/ManageAutomationsModal";
import EmptySoftware from "../components/EmptySoftware";
interface IManageSoftwarePageProps {
@@ -49,7 +59,17 @@ const ManageSoftwarePage = ({
router,
location,
}: IManageSoftwarePageProps): JSX.Element => {
const { availableTeams, currentTeam } = useContext(AppContext);
const dispatch = useDispatch();
const {
availableTeams,
currentTeam,
setAvailableTeams,
setCurrentUser,
setConfig,
isPremiumTier,
isGlobalAdmin,
isGlobalMaintainer,
} = useContext(AppContext);
const [isLoadingSoftware, setIsLoadingSoftware] = useState(true);
const [isLoadingCount, setIsLoadingCount] = useState(true);
@@ -62,6 +82,10 @@ const ManageSoftwarePage = ({
>(DEFAULT_SORT_DIRECTION);
const [sortHeader, setSortHeader] = useState(DEFAULT_SORT_HEADER);
const [pageIndex, setPageIndex] = useState(0);
const [showManageAutomationsModal, setShowManageAutomationsModal] = useState(
false
);
const [showPreviewPayloadModal, setShowPreviewPayloadModal] = useState(false);
// TODO: experiment to see if we need this state and effect or can we rely solely on the router/location for the dropdown state?
useEffect(() => {
@@ -70,6 +94,14 @@ const ManageSoftwarePage = ({
// TODO: combine string and object so only one array element in query key; figure out typing and
// destructuring for queryfn
useQuery(["me"], () => usersAPI.me(), {
onSuccess: ({ user, available_teams }: IGetMeResponse) => {
setCurrentUser(user);
setAvailableTeams(available_teams);
},
});
const { data: software, error: softwareError } = useQuery<
ISoftwareResponse,
Error
@@ -153,6 +185,22 @@ const ManageSoftwarePage = ({
}
);
const canAddOrRemoveSoftwareWebhook = isGlobalAdmin || isGlobalMaintainer;
const {
data: softwareVulnerabilitiesWebhook,
isLoading: isLoadingSoftwareVulnerabilitiesWebhook,
refetch: refetchSoftwareVulnerabilitiesWebhook,
} = useQuery<IConfigNested, Error, IWebhookSoftwareVulnerabilities>(
["config"],
() => configAPI.loadAll(),
{
enabled: canAddOrRemoveSoftwareWebhook,
select: (data: IConfigNested) =>
data.webhook_settings.vulnerabilities_webhook,
}
);
const onQueryChange = useDebouncedCallback(
async ({
pageIndex: newPageIndex,
@@ -173,6 +221,57 @@ const ManageSoftwarePage = ({
300
);
const toggleManageAutomationsModal = () =>
setShowManageAutomationsModal(!showManageAutomationsModal);
const togglePreviewPayloadModal = useCallback(() => {
setShowPreviewPayloadModal(!showPreviewPayloadModal);
}, [setShowPreviewPayloadModal, showPreviewPayloadModal]);
const onManageAutomationsClick = () => {
toggleManageAutomationsModal();
};
const onCreateWebhookSubmit = async ({
destination_url,
enable_vulnerabilities_webhook,
}: IWebhookSoftwareVulnerabilities) => {
try {
const request = configAPI.update({
webhook_settings: {
vulnerabilities_webhook: {
destination_url,
enable_vulnerabilities_webhook,
},
},
});
await request.then(() => {
dispatch(
renderFlash(
"success",
"Successfully updated vulnerability automations."
)
);
});
} catch {
dispatch(
renderFlash(
"error",
"Could not update vulnerability automations. Please try again."
)
);
} finally {
toggleManageAutomationsModal();
refetchSoftwareVulnerabilitiesWebhook();
// Config must be updated in both Redux and AppContext
dispatch(getConfig())
.then((configState: IConfig) => {
setConfig(configState);
})
.catch(() => false);
}
};
const onTeamSelect = () => {
setPageIndex(0);
};
@@ -181,12 +280,13 @@ const ManageSoftwarePage = ({
state: ITeamsDropdownState
): JSX.Element | null => {
if (
(state.isGlobalAdmin || state.isGlobalMaintainer) &&
state.teamId === 0
canAddOrRemoveSoftwareWebhook &&
(!isPremiumTier || state.teamId === 0) &&
!isLoadingSoftwareVulnerabilitiesWebhook
) {
return (
<Button
onClick={() => console.log("Manage automations button click")}
onClick={onManageAutomationsClick}
className={`${baseClass}__manage-automations button`}
variant="brand"
>
@@ -200,8 +300,11 @@ const ManageSoftwarePage = ({
const renderHeaderDescription = (state: ITeamsDropdownState) => {
return (
<p>
Search for installed software and manage automations for detected
vulnerabilities (CVEs) on{" "}
Search for installed software{" "}
{canAddOrRemoveSoftwareWebhook &&
(!isPremiumTier || state.teamId === 0) &&
"and manage automations for detected vulnerabilities (CVEs)"}{" "}
on{" "}
<b>
{state.isPremiumTier && !!state.teamId
? "all hosts assigned to this team"
@@ -224,7 +327,7 @@ const ManageSoftwarePage = ({
buttons={renderHeaderButtons}
/>
);
}, [router, location]);
}, [router, location, isLoadingSoftwareVulnerabilitiesWebhook]);
const renderSoftwareCount = useCallback(() => {
const count = softwareCount;
@@ -394,6 +497,24 @@ const ManageSoftwarePage = ({
highlightOnHover
/>
)}
{showManageAutomationsModal && (
<ManageAutomationsModal
onCancel={toggleManageAutomationsModal}
onCreateWebhookSubmit={onCreateWebhookSubmit}
togglePreviewPayloadModal={togglePreviewPayloadModal}
showPreviewPayloadModal={showPreviewPayloadModal}
softwareVulnerabilityWebhookEnabled={
softwareVulnerabilitiesWebhook &&
softwareVulnerabilitiesWebhook.enable_vulnerabilities_webhook
}
currentDestinationUrl={
(softwareVulnerabilitiesWebhook &&
softwareVulnerabilitiesWebhook.destination_url) ||
""
}
/>
)}
</div>
</div>
);
@@ -0,0 +1,244 @@
import React, { useState } from "react";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
// @ts-ignore
import Checkbox from "components/forms/fields/Checkbox";
// @ts-ignore
import InputField from "components/forms/fields/InputField";
import IconToolTip from "components/IconToolTip";
import validURL from "components/forms/validators/valid_url";
import { IWebhookSoftwareVulnerabilities } from "interfaces/webhook";
import { useDeepEffect } from "utilities/hooks";
import { size } from "lodash";
import PreviewPayloadModal from "../PreviewPayloadModal";
interface IManageAutomationsModalProps {
onCancel: () => void;
onCreateWebhookSubmit: (formData: IWebhookSoftwareVulnerabilities) => void;
togglePreviewPayloadModal: () => void;
showPreviewPayloadModal: boolean;
softwareVulnerabilityWebhookEnabled?: boolean;
currentDestinationUrl?: string;
}
interface ICheckedSoftwareAutomation {
name?: string;
accessor: string;
isChecked?: boolean;
}
const useCheckboxListStateManagement = (
softwareVulnerabilityWebhookEnabled = false
) => {
// Ability to add future software automations
const availableSoftwareAutomations: ICheckedSoftwareAutomation[] = [
{ accessor: "vulnerability", name: "Enable vulnerability automations" },
];
const currentSoftwareAutomations: ICheckedSoftwareAutomation[] = softwareVulnerabilityWebhookEnabled
? availableSoftwareAutomations
: [];
const [softwareAutomationsItems, setSoftwareAutomationsItems] = useState<
ICheckedSoftwareAutomation[]
>(() => {
return (
availableSoftwareAutomations &&
availableSoftwareAutomations.map(
(automation: ICheckedSoftwareAutomation) => {
return {
name: automation.name,
accessor: automation.accessor,
isChecked: currentSoftwareAutomations.some(
(currentSoftwareAutomationItem: ICheckedSoftwareAutomation) =>
currentSoftwareAutomationItem.accessor === automation.accessor
),
};
}
)
);
});
const updateSoftwareAutomationsItems = (
softwareAutomationAccessor: string
) => {
setSoftwareAutomationsItems((prevState) => {
const selectedSoftwareAutomation = softwareAutomationsItems.find(
(softwareAutomationItem) =>
softwareAutomationItem.accessor === softwareAutomationAccessor
);
const updatedSoftwareAutomation = selectedSoftwareAutomation && {
...selectedSoftwareAutomation,
isChecked:
!!selectedSoftwareAutomation && !selectedSoftwareAutomation.isChecked,
};
// this is replacing the softwareAutomation object with the updatedSoftwareAutomation we just created
const newState = prevState.map((currentSoftwareAutomation) => {
return currentSoftwareAutomation.accessor ===
softwareAutomationAccessor && updatedSoftwareAutomation
? updatedSoftwareAutomation
: currentSoftwareAutomation;
});
return newState;
});
};
return {
softwareAutomationsItems,
updateSoftwareAutomationsItems,
};
};
const validateWebhookURL = (url: string) => {
const errors: { [key: string]: string } = {};
if (!validURL(url)) {
errors.url = "Please add a valid destination URL";
}
const valid = !size(errors);
return { valid, errors };
};
const baseClass = "manage-automations-modal";
const ManageAutomationsModal = ({
onCancel: onReturnToApp,
onCreateWebhookSubmit,
togglePreviewPayloadModal,
showPreviewPayloadModal,
softwareVulnerabilityWebhookEnabled,
currentDestinationUrl,
}: IManageAutomationsModalProps): JSX.Element => {
const [destination_url, setDestinationUrl] = useState<string>(
currentDestinationUrl || ""
);
const [errors, setErrors] = useState<{ [key: string]: string }>({});
const {
softwareAutomationsItems,
updateSoftwareAutomationsItems,
} = useCheckboxListStateManagement(softwareVulnerabilityWebhookEnabled);
useDeepEffect(() => {
if (destination_url) {
setErrors({});
}
}, [destination_url]);
const onURLChange = (value: string) => {
setDestinationUrl(value);
};
const handleSaveAutomation = (evt: React.MouseEvent<HTMLFormElement>) => {
evt.preventDefault();
const { valid, errors: newErrors } = validateWebhookURL(destination_url);
setErrors({
...errors,
...newErrors,
});
if (valid) {
// Ability to add future software automations
const vulnerabilityWebhook = softwareAutomationsItems.find(
(softwareAutomationItem) =>
softwareAutomationItem.accessor === "vulnerability"
);
onCreateWebhookSubmit({
destination_url,
enable_vulnerabilities_webhook: vulnerabilityWebhook?.isChecked,
});
onReturnToApp();
}
};
if (showPreviewPayloadModal) {
return <PreviewPayloadModal onCancel={togglePreviewPayloadModal} />;
}
return (
<Modal
onExit={onReturnToApp}
title={"Manage automations"}
className={baseClass}
>
<div className={baseClass}>
<div className={`${baseClass}__software-select-items`}>
{softwareAutomationsItems &&
softwareAutomationsItems.map(
(softwareItem: ICheckedSoftwareAutomation) => {
const { isChecked, name, accessor } = softwareItem;
return (
<div key={accessor} className={`${baseClass}__team-item`}>
<Checkbox
value={isChecked}
name={name}
onChange={() =>
updateSoftwareAutomationsItems(softwareItem.accessor)
}
>
{name}
</Checkbox>
</div>
);
}
)}
</div>
<div className="tooltip-wrap tooltip-wrap--input">
<InputField
inputWrapperClass={`${baseClass}__url-input`}
name="webhook-url"
label={"Destination URL"}
type={"text"}
value={destination_url}
onChange={onURLChange}
error={errors.url}
hint={
"For each new vulnerability detected, Fleet will send a JSON payload to this URL with a list of the affected hosts."
}
placeholder={"https://server.com/example"}
/>
<IconToolTip
isHtml
text={"<p>Provide a URL to deliver a<br />webhook request to.</p>"}
/>
</div>
<Button
type="button"
variant="text-link"
onClick={togglePreviewPayloadModal}
>
Preview payload
</Button>
<div className={`${baseClass}__button-wrap`}>
<Button
className={`${baseClass}__btn`}
onClick={onReturnToApp}
variant="inverse"
>
Cancel
</Button>
<Button
className={`${baseClass}__btn`}
type="submit"
variant="brand"
onClick={handleSaveAutomation}
>
Save
</Button>
</div>
</div>
</Modal>
);
};
export default ManageAutomationsModal;
@@ -0,0 +1,69 @@
.manage-automations-modal {
width: 778px;
&__button-wrap {
display: flex;
justify-content: flex-end;
margin: $pad-large 0 0;
}
.button--inverse {
padding: $pad-small;
margin-right: $pad-small;
}
pre,
code {
background-color: $ui-off-white;
color: $core-fleet-blue;
border: 1px solid $ui-fleet-blue-15;
border-radius: 4px;
padding: 7px $pad-medium;
margin: $pad-large 0 0 44px;
}
&__error {
color: $ui-error;
}
.tooltip-wrap {
display: flex;
align-items: center;
width: 100%;
margin-bottom: $pad-large;
.icon-tooltip {
span {
display: flex;
align-items: center;
}
p {
text-align: center;
}
}
.form-field--input {
display: flex;
flex-direction: column;
width: 100%;
margin-right: $pad-small;
margin-bottom: 0;
}
.form-field__hint {
font-style: normal;
}
}
.tooltip-wrap--input {
.icon-tooltip {
span {
margin-top: -5px;
}
}
}
.form-field__label--error {
color: $ui-error;
}
}
@@ -0,0 +1 @@
export { default } from "./ManageAutomationsModal";
@@ -0,0 +1,72 @@
import React from "react";
import { syntaxHighlight } from "fleet/helpers";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
// @ts-ignore
import FleetIcon from "components/icons/FleetIcon";
const baseClass = "preview-data-modal";
interface IPreviewPayloadModalProps {
onCancel: () => void;
}
const PreviewPayloadModal = ({
onCancel,
}: IPreviewPayloadModalProps): JSX.Element => {
const json = {
timestamp: "0000-00-00T00:00:00Z",
vulnerability: {
cve: "CVE-2014-9471",
details_link: "https://nvd.nist.gov/vuln/detail/CVE-2014-9471",
hosts_affected: [
{
id: 1,
hostname: "macbook-1",
url: "https://fleet.example.com/hosts/1",
},
{
id: 2,
hostname: "macbook-2",
url: "https://fleet.example.com/hosts/2",
},
],
},
};
return (
<Modal title={"Example payload"} onExit={onCancel} className={baseClass}>
<div className={`${baseClass}__preview-modal`}>
<p>
Want to learn more about how automations in Fleet work?{" "}
<a
href="https://fleetdm.com/docs/using-fleet/automations"
target="_blank"
rel="noopener noreferrer"
>
Check out the Fleet documentation&nbsp;
<FleetIcon name="external-link" />
</a>
</p>
<div className={`${baseClass}__payload-request-preview`}>
<pre>POST https://server.com/example</pre>
</div>
<div className={`${baseClass}__payload-webhook-preview`}>
<pre dangerouslySetInnerHTML={{ __html: syntaxHighlight(json) }} />
</div>
<div className={`${baseClass}__btn-wrap`}>
<Button
className={`${baseClass}__btn`}
onClick={onCancel}
variant="brand"
>
Done
</Button>
</div>
</div>
</Modal>
);
};
export default PreviewPayloadModal;
@@ -0,0 +1,38 @@
.preview-payload-modal {
&__sandbox-info {
margin-top: $pad-medium;
p {
margin: 0;
margin-bottom: $pad-medium;
}
p:last-child {
margin-bottom: 0;
}
}
a {
color: $core-vibrant-blue;
font-weight: $bold;
font-size: $x-small;
text-decoration: none;
}
&__btn-wrap {
display: flex;
flex-direction: row-reverse;
}
&__btn {
margin-left: 12px;
}
.tooltip__tooltip-icon {
img {
width: 16px;
height: 16px;
vertical-align: top;
}
}
}
@@ -0,0 +1 @@
export { default } from "./PreviewPayloadModal";