Schedule Page: Team maintainer can modify their team's schedule (#2243)

* Change permissions to schedule UI to include team maintainers
* Update e2e tests
This commit is contained in:
RachelElysia 2021-09-30 10:30:44 -04:00 committed by GitHub
parent e3ff9e4274
commit f397b77c1a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 276 additions and 145 deletions

View file

@ -0,0 +1 @@
- Team maintainer now has permissions to modify their team's schedule

View file

@ -38,6 +38,7 @@ describe(
cy.findByText(/query all/i).click();
cy.wait(2000); // eslint-disable-line cypress/no-unnecessary-waiting
cy.findByText(/run query/i).should("exist");
cy.get(".ace_scroller")
@ -48,79 +49,7 @@ describe(
cy.findByText(/query updated/i).should("be.visible");
// // Start e2e test for schedules
// cy.visit("/schedule/manage");
// cy.wait(1000); // eslint-disable-line cypress/no-unnecessary-waiting
// cy.findByRole("button", { name: /schedule a query/i }).click();
// cy.findByText(/select query/i).click();
// cy.findByText(/query all window crashes/i).click();
// cy.get(
// ".schedule-editor-modal__form-field--frequency > .dropdown__select"
// ).click();
// cy.findByText(/every week/i).click();
// cy.findByText(/show advanced options/i).click();
// cy.get(
// ".schedule-editor-modal__form-field--logging > .dropdown__select"
// ).click();
// cy.findByText(/ignore removals/i).click();
// cy.get(".schedule-editor-modal__form-field--shard > .input-field")
// .click()
// .type("50");
// cy.get(".schedule-editor-modal__btn-wrap")
// .contains("button", /schedule/i)
// .click();
// cy.visit("/schedule/manage");
// cy.wait(2000); // eslint-disable-line cypress/no-unnecessary-waiting
// cy.findByText(/query all window crashes/i).should("exist");
// cy.findByText(/actions/i).click();
// cy.findByText(/edit/i).click();
// cy.get(
// ".schedule-editor-modal__form-field--frequency > .dropdown__select"
// ).click();
// cy.findByText(/every 6 hours/i).click();
// cy.findByText(/show advanced options/i).click();
// cy.findByText(/ignore removals/i).click();
// cy.findByText(/snapshot/i).click();
// cy.get(".schedule-editor-modal__form-field--shard > .input-field")
// .click()
// .type("{selectall}{backspace}10");
// cy.get(".schedule-editor-modal__btn-wrap")
// .contains("button", /schedule/i)
// .click();
// cy.visit("/schedule/manage");
// cy.wait(2000); // eslint-disable-line cypress/no-unnecessary-waiting
// cy.findByText(/actions/i).click();
// cy.findByText(/remove/i).click();
// cy.get(".remove-scheduled-query-modal__btn-wrap")
// .contains("button", /remove/i)
// .click();
// cy.findByText(/query all window crashes/i).should("not.exist");
// // End e2e test for schedules
// E2e Test for schedules moved to premium/admin
cy.visit("/queries/manage");

View file

@ -22,7 +22,7 @@ describe(
cy.visit("/");
// Ensure page is loaded
cy.wait(3000); // eslint-disable-line cypress/no-unnecessary-waiting
// cy.wait(3000); // eslint-disable-line cypress/no-unnecessary-waiting
cy.contains("All hosts");
// Settings restrictions

View file

@ -78,12 +78,143 @@ describe(
// cy.findByText(/teams/i).should("exist");
// });
cy.visit("/queries/manage");
cy.findByRole("button", { name: /create new query/i }).click();
// Using class selector because third party element doesn't work with Cypress Testing Selector Library
cy.get(".ace_scroller")
.click({ force: true })
.type("{selectall}SELECT * FROM windows_crashes;");
cy.findByRole("button", { name: /save/i }).click();
// save modal
cy.get(".query-form__query-save-modal-name")
.click()
.type("Query all window crashes");
cy.get(".query-form__query-save-modal-description")
.click()
.type("See all window crashes");
cy.findByRole("button", { name: /save query/i }).click();
cy.findByText(/query created/i).should("exist");
cy.findByText(/back to queries/i).should("exist");
cy.visit("/queries/manage");
cy.findByText(/query all/i).click();
cy.wait(2000); // eslint-disable-line cypress/no-unnecessary-waiting
cy.findByText(/run query/i).should("exist");
cy.get(".ace_scroller")
.click({ force: true })
.type("{selectall}SELECT datetime, username FROM windows_crashes;");
cy.findByRole("button", { name: /^Save$/ }).click();
cy.findByText(/query updated/i).should("be.visible");
// Start e2e test for schedules
cy.visit("/schedule/manage");
cy.wait(2000); // eslint-disable-line cypress/no-unnecessary-waiting
cy.findByRole("button", { name: /schedule a query/i }).click();
cy.findByText(/select query/i).click();
cy.findByText(/query all window crashes/i).click();
cy.get(
".schedule-editor-modal__form-field--frequency > .dropdown__select"
).click();
cy.findByText(/every week/i).click();
cy.findByText(/show advanced options/i).click();
cy.get(
".schedule-editor-modal__form-field--logging > .dropdown__select"
).click();
cy.findByText(/ignore removals/i).click();
cy.get(".schedule-editor-modal__form-field--shard > .input-field")
.click()
.type("50");
cy.get(".schedule-editor-modal__btn-wrap")
.contains("button", /schedule/i)
.click();
cy.visit("/schedule/manage");
cy.wait(2000); // eslint-disable-line cypress/no-unnecessary-waiting
cy.findByText(/query all window crashes/i).should("exist");
cy.findByText(/actions/i).click();
cy.findByText(/edit/i).click();
cy.get(
".schedule-editor-modal__form-field--frequency > .dropdown__select"
).click();
cy.findByText(/every 6 hours/i).click();
cy.findByText(/show advanced options/i).click();
cy.findByText(/ignore removals/i).click();
cy.findByText(/snapshot/i).click();
cy.get(".schedule-editor-modal__form-field--shard > .input-field")
.click()
.type("{selectall}{backspace}10");
cy.get(".schedule-editor-modal__btn-wrap")
.contains("button", /schedule/i)
.click();
cy.visit("/schedule/manage");
cy.wait(2000); // eslint-disable-line cypress/no-unnecessary-waiting
cy.findByText(/actions/i).click();
cy.findByText(/remove/i).click();
cy.get(".remove-scheduled-query-modal__btn-wrap")
.contains("button", /remove/i)
.click();
cy.findByText(/query all window crashes/i).should("not.exist");
// End e2e test for schedules
cy.visit("/queries/manage");
cy.findByText(/query all window crashes/i)
.parent()
.parent()
.within(() => {
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-query-modal")
.contains("button", /delete/i)
.click();
cy.findByText(/successfully removed query/i).should("be.visible");
cy.findByText(/query all/i).should("not.exist");
// On the Packs pages (manage, new, and edit), they should…
// ^^General admin functionality for packs page is being tested in app/packflow.spec.ts
// On the Schedule pages (manage, new, and edit), they should…
// ^^General admin functionality for packs page is being tested in app/queryflow.spec.ts
// On the Settings pages, they should…
// See the “Teams” navigation item and access the Settings - Teams page
cy.visit("/settings/organization");

View file

@ -34,13 +34,11 @@ describe(
// Nav restrictions
cy.findByText(/settings/i).should("not.exist");
cy.findByText(/schedule/i).should("not.exist");
cy.findByText(/schedule/i).should("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");
// NOT see and select "add label"
cy.findByRole("button", { name: /new label/i }).should("not.exist");
@ -120,7 +118,7 @@ describe(
cy.get("nav").within(() => {
cy.findByText(/hosts/i).should("exist");
cy.findByText(/queries/i).should("exist");
cy.findByText(/schedule/i).should("not.exist");
cy.findByText(/schedule/i).should("exist");
cy.findByText(/settings/i).should("not.exist");
});
@ -140,7 +138,7 @@ describe(
// ^^TODO hosts table is not rendering because we need new forEach script/command for admin to assign team after the host is added
// See and select the “Add new host” button
cy.findByText(/add new host/i).click();
cy.findByRole("button", { name: /add new host/i }).click();
// See the “Select a team for this new host” in the Add new host modal. This modal appears after the user selects the “Add new host” button
cy.get(".add-host-modal__team-dropdown-wrapper .Select-control").click();
@ -191,6 +189,15 @@ describe(
// });
// });
// On the Schedule page, they should
// See Oranges (team they maintain) only, not able to reach packs, able to schedule a query
cy.visit("/schedule/manage");
cy.findByText(/oranges/i).click();
cy.findByText(/apples/i).should("not.exist");
cy.findByText(/advanced/i).should("not.exist");
cy.findByRole("button", { name: /schedule a query/i }).click();
// TODO: Write e2e test for team maintainer to schedule a query
// On the Profile page, they should…
// See 2 Teams in the Team section and Various in the Role section
cy.visit("/profile");

View file

@ -75,12 +75,15 @@ The following table depicts various permissions levels in a team.
| Filter hosts assigned to team using labels | ✅ | ✅ |
| Target hosts assigned to team using labels | ✅ | ✅ |
| Run saved queries as live queries on hosts assigned to team | ✅ | ✅ |
| Create new team policies | | ✅ |
| Delete team policies | | ✅ |
| Run custom queries as live queries on hosts assigned to team | | ✅ |
| Enroll hosts to member team | | ✅ |
| Delete hosts belonging to member team | | ✅ |
| Edit queries they authored | | ✅ |
| Delete queries they authored | | ✅ |
| Browse global policies | | ✅ |
| Create new team schedules | | ✅ |
| Delete team schedules | | ✅ |
| Browse global schedules | | ✅ |
| Create new team policies | | ✅ |
| Delete team policies | | ✅ |
| Browse global policies | | ✅ |

View file

@ -48,34 +48,6 @@ class SiteTopNav extends Component {
const iconClasses = classnames([`${navItemBaseClass}__icon`]);
let icon = (
<img src={HostsIcon} alt={`${iconName} icon`} className={iconClasses} />
);
if (iconName === "queries")
icon = (
<img
src={QueriesIcon}
alt={`${iconName} icon`}
className={iconClasses}
/>
);
else if (iconName === "packs")
icon = (
<img src={PacksIcon} alt={`${iconName} icon`} className={iconClasses} />
);
else if (iconName === "policies")
icon = (
<img
src={PoliciesIcon}
alt={`${iconName} icon`}
className={iconClasses}
/>
);
else if (iconName === "settings")
icon = (
<img src={AdminIcon} alt={`${iconName} icon`} className={iconClasses} />
);
if (iconName === "logo") {
return (
<li className={navItemClasses} key={`nav-item-${name}`}>
@ -88,6 +60,26 @@ class SiteTopNav extends Component {
</li>
);
}
const iconImage = () => {
switch (iconName) {
case "hosts":
return HostsIcon;
case "queries":
return QueriesIcon;
case "packs":
return PacksIcon;
case "policies":
return PoliciesIcon;
default:
return AdminIcon;
}
};
const icon = (
<img src={iconImage()} alt={`${iconName} icon`} className={iconClasses} />
);
return (
<li className={navItemClasses} key={`nav-item-${name}`}>
<a

View file

@ -45,7 +45,7 @@ export default (currentUser) => {
},
];
const globalMaintainerNavItems = [
const teamMaintainerNavItems = [
{
icon: "packs",
name: "Schedule",
@ -55,6 +55,9 @@ export default (currentUser) => {
pathname: PATHS.MANAGE_SCHEDULE,
},
},
];
const globalMaintainerNavItems = [
{
icon: "policies",
name: "Policies",
@ -67,11 +70,24 @@ export default (currentUser) => {
];
if (permissionUtils.isGlobalAdmin(currentUser)) {
return [...userNavItems, ...globalMaintainerNavItems, ...adminNavItems];
return [
...userNavItems,
...teamMaintainerNavItems,
...globalMaintainerNavItems,
...adminNavItems,
];
}
if (permissionUtils.isGlobalMaintainer(currentUser)) {
return [...userNavItems, ...globalMaintainerNavItems];
return [
...userNavItems,
...teamMaintainerNavItems,
...globalMaintainerNavItems,
];
}
if (permissionUtils.isAnyTeamMaintainer(currentUser)) {
return [...userNavItems, ...teamMaintainerNavItems];
}
return userNavItems;

View file

@ -8,6 +8,7 @@ import { push } from "react-router-redux";
import deepDifference from "utilities/deep_difference";
import { IConfig } from "interfaces/config";
import { IQuery } from "interfaces/query";
import { IUser } from "interfaces/user";
import { ITeam } from "interfaces/team";
import { IGlobalScheduledQuery } from "interfaces/global_scheduled_query";
import { ITeamScheduledQuery } from "interfaces/team_scheduled_query";
@ -20,6 +21,7 @@ import queryActions from "redux/nodes/entities/queries/actions";
import teamActions from "redux/nodes/entities/teams/actions";
// @ts-ignore
import { renderFlash } from "redux/nodes/notifications/actions";
import permissionUtils from "utilities/permissions";
import paths from "router/paths";
import Button from "components/buttons/Button";
@ -41,7 +43,8 @@ const renderTable = (
allScheduledQueriesList: IGlobalScheduledQuery[] | ITeamScheduledQuery[],
allScheduledQueriesError: { name: string; reason: string }[],
toggleScheduleEditorModal: () => void,
teamId: number
teamId: number,
isTeamMaintainer: boolean
): JSX.Element => {
if (Object.keys(allScheduledQueriesError).length !== 0) {
return <TableDataError />;
@ -54,12 +57,14 @@ const renderTable = (
allScheduledQueriesList={allScheduledQueriesList}
toggleScheduleEditorModal={toggleScheduleEditorModal}
teamId={teamId}
isTeamMaintainer={isTeamMaintainer}
/>
);
};
const renderAllTeamsTable = (
teamId: number,
isTeamMaintainer: boolean,
allTeamsScheduledQueriesList: IGlobalScheduledQuery[],
allTeamsScheduledQueriesError: { name: string; reason: string }[]
): JSX.Element => {
@ -73,6 +78,7 @@ const renderAllTeamsTable = (
inheritedQueries
allScheduledQueriesList={allTeamsScheduledQueriesList}
teamId={teamId}
isTeamMaintainer={isTeamMaintainer}
/>
</div>
);
@ -87,6 +93,9 @@ interface IRootState {
app: {
config: IConfig;
};
auth: {
user: IUser;
};
entities: {
global_scheduled_queries: {
isLoading: boolean;
@ -149,6 +158,16 @@ const ManageSchedulePage = (props: ITeamSchedulesPageProps): JSX.Element => {
return state.app.config.tier === "premium";
});
const user = useSelector(
(state: IRootState): IUser => {
return state.auth.user;
}
);
const isTeamMaintainer = useSelector((state: IRootState): boolean => {
return permissionUtils.isAnyTeamMaintainer(state.auth.user);
});
const allQueries = useSelector((state: IRootState) => state.entities.queries);
const allQueriesList = Object.values(allQueries.data);
@ -180,21 +199,34 @@ const ManageSchedulePage = (props: ITeamSchedulesPageProps): JSX.Element => {
const selectedTeam = isNaN(teamId) ? "global" : teamId;
const generateTeamOptionsDropdownItems = (): ITeamOptions[] => {
const teamOptions: ITeamOptions[] = [
{
const teamOptions: ITeamOptions[] = [];
if (isTeamMaintainer) {
user.teams.forEach((team) => {
if (team.role === "maintainer") {
teamOptions.push({
disabled: false,
label: team.name,
value: team.id,
});
}
});
} else {
teamOptions.push({
disabled: false,
label: "All teams",
value: "global",
},
];
allTeamsList.forEach((team) => {
teamOptions.push({
disabled: false,
label: team.name,
value: team.id,
});
});
allTeamsList.forEach((team) => {
teamOptions.push({
disabled: false,
label: team.name,
value: team.id,
});
});
}
return teamOptions;
};
@ -349,6 +381,15 @@ const ManageSchedulePage = (props: ITeamSchedulesPageProps): JSX.Element => {
}
};
if (selectedTeam === "global" && isTeamMaintainer) {
const teamMaintainerTeams = generateTeamOptionsDropdownItems();
dispatch(
push(
`${paths.MANAGE_TEAM_SCHEDULE(Number(teamMaintainerTeams[0].value))}`
)
);
}
return (
<div className={baseClass}>
<div className={`${baseClass}__wrapper body-wrap`}>
@ -398,13 +439,15 @@ const ManageSchedulePage = (props: ITeamSchedulesPageProps): JSX.Element => {
{allScheduledQueriesList.length !== 0 &&
allScheduledQueriesError.length !== 0 && (
<div className={`${baseClass}__action-button-container`}>
<Button
variant="inverse"
onClick={handleAdvanced}
className={`${baseClass}__advanced-button`}
>
Advanced
</Button>
{!isTeamMaintainer && (
<Button
variant="inverse"
onClick={handleAdvanced}
className={`${baseClass}__advanced-button`}
>
Advanced
</Button>
)}
<Button
variant="brand"
className={`${baseClass}__schedule-button`}
@ -422,7 +465,8 @@ const ManageSchedulePage = (props: ITeamSchedulesPageProps): JSX.Element => {
allScheduledQueriesList,
allScheduledQueriesError,
toggleScheduleEditorModal,
teamId
teamId,
isTeamMaintainer
)}
</div>
{/* must use ternary for NaN */}
@ -455,6 +499,7 @@ const ManageSchedulePage = (props: ITeamSchedulesPageProps): JSX.Element => {
{showInheritedQueries &&
renderAllTeamsTable(
teamId,
isTeamMaintainer,
allTeamsScheduledQueriesList,
allTeamsScheduledQueriesError
)}

View file

@ -33,6 +33,7 @@ interface IScheduleListWrapperProps {
toggleScheduleEditorModal?: () => void;
teamId: number;
inheritedQueries?: boolean;
isTeamMaintainer: boolean;
}
interface IRootState {
entities: {
@ -55,6 +56,7 @@ const ScheduleListWrapper = (props: IScheduleListWrapperProps): JSX.Element => {
onEditScheduledQueryClick,
teamId,
inheritedQueries,
isTeamMaintainer,
} = props;
const dispatch = useDispatch();
const { MANAGE_PACKS } = paths;
@ -69,8 +71,9 @@ const ScheduleListWrapper = (props: IScheduleListWrapperProps): JSX.Element => {
<div className={`${noScheduleClass}__inner-text`}>
<h2>You don&apos;t have any queries scheduled.</h2>
<p>
Schedule a query, or go to your osquery packs via the
&lsquo;Advanced&rsquo; button.
{!isTeamMaintainer
? "Schedule a query, or go to your osquery packs via the &lsquo;Advanced&rsquo; button."
: "Schedule a query to run on hosts assigned to this team."}
</p>
<div className={`${noScheduleClass}__-cta-buttons`}>
<Button
@ -80,13 +83,15 @@ const ScheduleListWrapper = (props: IScheduleListWrapperProps): JSX.Element => {
>
Schedule a query
</Button>
<Button
variant="inverse"
onClick={handleAdvanced}
className={`${baseClass}__advanced-button`}
>
Advanced
</Button>
{!isTeamMaintainer && (
<Button
variant="inverse"
onClick={handleAdvanced}
className={`${baseClass}__advanced-button`}
>
Advanced
</Button>
)}
</div>
</div>
</div>

View file

@ -129,6 +129,8 @@ const routes = (
<Route path="policies" component={PoliciesPageWrapper}>
<Route path="manage" component={ManagePoliciesPage} />
</Route>
</Route>
<Route component={AuthAnyMaintainerGlobalAdminRoutes}>
<Route path="schedule" component={SchedulePageWrapper}>
<Route path="manage" component={ManageSchedulePage} />
<Route