Skip to content

Commit

Permalink
Group management filtering (#387)
Browse files Browse the repository at this point in the history
* initial AssetGroupFilters

* initial setup

* use availableNodeKinds

* styles

* add keys and narrow filter in AssetGroupEdit

* handleClearFilters

* include domain in asset group edit requests

* only filter on node types available for that domain

* fix class naming

* remove empty file

* deconstruct props in func def

* explicitly delete custom_member field

* fix breaking test

* disable button until filters are active, and add empty filter state

* update correct AssetGroupMemberParams type

* remove large desktop view

* add AssetGroupFilters.test.tsx

* remove log

* test verbiage and fix expand filters button style targeting

* fix for queueingthe react state change

* explicity set custome_member to true to test checkbox
  • Loading branch information
benwaples authored Feb 6, 2024
1 parent c34819a commit 8cceb88
Show file tree
Hide file tree
Showing 10 changed files with 441 additions and 14 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ describe('AssetGroupEdit', () => {
const setup = async () => {
const user = userEvent.setup();
const screen = await act(async () => {
return render(<AssetGroupEdit assetGroup={assetGroup} filter={{}} />);
return render(<AssetGroupEdit assetGroup={assetGroup} filter={{}} makeNodeFilterable={() => ({})} />);
});
return { user, screen };
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ import { useNotifications } from '../../providers';
const AssetGroupEdit: FC<{
assetGroup: AssetGroup;
filter: AssetGroupMemberParams;
}> = ({ assetGroup, filter }) => {
makeNodeFilterable: (node: ActiveDirectoryNodeKind | AzureNodeKind) => void;
}> = ({ assetGroup, filter, makeNodeFilterable }) => {
const [changelog, setChangelog] = useState<AssetGroupChangelog>([]);
const addRows = changelog.filter((entry) => entry.action === ChangelogAction.ADD);
const removeRows = changelog.filter((entry) => entry.action === ChangelogAction.REMOVE);
Expand All @@ -61,7 +62,7 @@ const AssetGroupEdit: FC<{
};

// Clear out changelog when group/domain changes
useEffect(() => setChangelog([]), [filter]);
useEffect(() => setChangelog([]), [filter.environment_id, filter.environment_kind]);

const mutation = useMutation({
mutationFn: () => {
Expand Down Expand Up @@ -92,7 +93,11 @@ const AssetGroupEdit: FC<{

return (
<Box component={Paper} elevation={0} padding={1}>
<FilteredMemberCountDisplay assetGroupId={assetGroup.id} label='Total Count' filter={filter} />
<FilteredMemberCountDisplay
assetGroupId={assetGroup.id}
label='Total Count'
filter={{ environment_id: filter.environment_id }}
/>
<AssetGroupAutocomplete
assetGroup={assetGroup}
changelog={changelog}
Expand All @@ -108,26 +113,32 @@ const AssetGroupEdit: FC<{
/>
)}
{Object.values(ActiveDirectoryNodeKind).map((kind) => {
const filterByKind = { ...filter, primary_kind: `eq:${kind}` };
const { environment_id, environment_kind } = filter;
const narrowedFilter = { primary_kind: `eq:${kind}`, environment_id, environment_kind };
const label = ActiveDirectoryNodeKindToDisplay(kind) || '';

return (
<FilteredMemberCountDisplay
key={label}
assetGroupId={assetGroup.id}
label={label}
filter={filterByKind}
filter={narrowedFilter}
makeNodeKindFilterable={() => makeNodeFilterable(kind)}
/>
);
})}
{Object.values(AzureNodeKind).map((kind) => {
const filterByKind = { ...filter, primary_kind: `eq:${kind}` };
const { environment_id, environment_kind } = filter;
const narrowedFilter = { primary_kind: `eq:${kind}`, environment_id, environment_kind };
const label = AzureNodeKindToDisplay(kind) || '';

return (
<FilteredMemberCountDisplay
key={label}
assetGroupId={assetGroup.id}
label={label}
filter={filterByKind}
filter={narrowedFilter}
makeNodeKindFilterable={() => makeNodeFilterable(kind)}
/>
);
})}
Expand All @@ -139,7 +150,8 @@ const FilteredMemberCountDisplay: FC<{
assetGroupId: number;
label: string;
filter: AssetGroupMemberParams;
}> = ({ assetGroupId, label, filter }) => {
makeNodeKindFilterable?: () => void;
}> = ({ assetGroupId, label, filter, makeNodeKindFilterable }) => {
const {
data: count,
isError,
Expand All @@ -150,6 +162,12 @@ const FilteredMemberCountDisplay: FC<{

const hasValidCount = !isLoading && !isError && count && count > 0;

useEffect(() => {
if (hasValidCount) {
makeNodeKindFilterable?.();
}
}, [hasValidCount, makeNodeKindFilterable]);

if (hasValidCount) {
return <SubHeader label={label} count={count} />;
} else {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// Copyright 2024 Specter Ops, Inc.
//
// Licensed under the Apache License, Version 2.0
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0

import { createMockAssetGroupMemberParams, createMockAvailableNodeKinds } from '../../mocks/factories';
import { act, render } from '../../test-utils';
import AssetGroupFilters, { FILTERABLE_PARAMS } from './AssetGroupFilters';
import userEvent from '@testing-library/user-event';
import { Screen, waitFor } from '@testing-library/react';
import { AssetGroupMemberParams } from 'js-client-library';
import { ActiveDirectoryNodeKind, AzureNodeKind } from '../..';

const filterParams = createMockAssetGroupMemberParams();
const availableNodeKinds = createMockAvailableNodeKinds();

describe('AssetGroupEdit', () => {
const setup = async (options?: {
filterParams?: AssetGroupMemberParams;
availableNodeKinds?: Array<ActiveDirectoryNodeKind | AzureNodeKind>;
}) => {
const user = userEvent.setup();
const handleFilterChange = vi.fn();
const screen: Screen = await act(async () => {
return render(
<AssetGroupFilters
filterParams={options?.filterParams ?? {}}
handleFilterChange={handleFilterChange}
availableNodeKinds={options?.availableNodeKinds ?? []}
/>
);
});
return { user, screen, handleFilterChange };
};

it('renders a button that expands the filter section', async () => {
const { screen, user } = await setup({ filterParams, availableNodeKinds });
const filtersButton = screen.getByTestId('display-filters-button');
const collapsedSection = screen.getByTestId('asset-group-filter-collapsible-section');

expect(filtersButton).toBeInTheDocument();
expect(collapsedSection.classList.contains('MuiCollapse-hidden')).toBeTruthy();

await user.click(filtersButton);

const expandedSection = screen.getByTestId('asset-group-filter-collapsible-section');
// we need to wait a moment while MUI runs the animation to expand this section
await waitFor(() => expect(expandedSection.classList.contains('MuiCollapse-entered')).toBeTruthy());
});

it('indicates that filters are active', async () => {
const { screen } = await setup({ filterParams, availableNodeKinds });

const filtersContainer = screen.getByTestId('asset-group-filters-container');

expect(filtersContainer.className.includes('activeFilters')).toBeTruthy();
});

it('indicates that filters are inactive', async () => {
const { screen } = await setup();

const filtersContainer = screen.getByTestId('asset-group-filters-container');

expect(filtersContainer.className.includes('activeFilters')).toBeFalsy();
});

describe('Node Type dropdown filter', () => {
it('displays the value from filterParams.node_type', async () => {
const { screen } = await setup({ filterParams, availableNodeKinds });
const nodeTypeFilter = screen.getByTestId('asset-groups-node-type-filter');
const nodeTypeFilterValue = nodeTypeFilter.firstChild?.nextSibling;

expect(nodeTypeFilter.textContent).toContain('Domain');
expect((nodeTypeFilterValue as HTMLInputElement)?.value).toBe('eq:Domain');
});

it('lists all available node kinds as options to filter by', async () => {
const { screen, user } = await setup({ availableNodeKinds });

await user.click(screen.getByTestId('display-filters-button'));
await user.click(screen.getByLabelText('Node Type'));

const nodeKindList = await screen.findAllByRole('option');

expect(nodeKindList).toHaveLength(availableNodeKinds.length + 1); // +1 for the default empty value

for (const nodeKind of availableNodeKinds) {
expect(screen.getByText(nodeKind)).toBeInTheDocument();
}
});

it('calls handleFilterChange when a node type is selected', async () => {
const { screen, user, handleFilterChange } = await setup({ availableNodeKinds });

await user.click(screen.getByTestId('display-filters-button'));
await user.click(screen.getByLabelText('Node Type'));
await user.click(screen.getByText(availableNodeKinds[0]));

expect(handleFilterChange).toBeCalledTimes(1);
expect(handleFilterChange).toHaveBeenCalledWith('primary_kind', `eq:${availableNodeKinds[0]}`);
});
});

describe('Custom Member checkbox filter', () => {
it("displays the checkbox as checked if the filter params value is 'true'", async () => {
const { screen } = await setup({ filterParams: { custom_member: 'eq:true' }, availableNodeKinds });
const checkbox = screen.getByTestId('asset-groups-custom-member-filter');

expect((checkbox.firstChild as HTMLInputElement)?.checked).toBe(true);
});

it('invokes handleFilterChange with eq:false when clicked and custom_member filter is on', async () => {
const { screen, user, handleFilterChange } = await setup({ filterParams, availableNodeKinds });
const checkbox = screen.getByTestId('asset-groups-custom-member-filter');

await user.click(checkbox);

expect(handleFilterChange).toBeCalledTimes(1);
expect(handleFilterChange).toBeCalledWith('custom_member', 'eq:false');
});

it('invokes handleFilterChange with eq:true when clicked and custom_member filter is off', async () => {
const { screen, user, handleFilterChange } = await setup();
const checkbox = screen.getByTestId('asset-groups-custom-member-filter');

await user.click(checkbox);

expect(handleFilterChange).toBeCalledTimes(1);
expect(handleFilterChange).toBeCalledWith('custom_member', 'eq:true');
});
});

describe('Clear Filters button', () => {
it('has a button with text Clear Filters', async () => {
const { screen } = await setup({ filterParams, availableNodeKinds });
const clearFilersButton = screen.getByText('Clear Filters');

expect(clearFilersButton).toBeInTheDocument();
});

it('calls handleFilterChange with all filter types and empty strings when clicked while filters are active', async () => {
const { screen, user, handleFilterChange } = await setup({ filterParams, availableNodeKinds });
const clearFilersButton = screen.getByText('Clear Filters');

await user.click(clearFilersButton);

expect(handleFilterChange).toBeCalledTimes(FILTERABLE_PARAMS.length);
FILTERABLE_PARAMS.forEach((filter) => {
expect(handleFilterChange).toBeCalledWith(filter, '');
});
});

it('is disabled if no filters are active', async () => {
const { screen } = await setup();
const clearFilersButton: HTMLButtonElement = screen.getByText('Clear Filters');

expect(clearFilersButton.disabled).toBe(true);
});
});
});
Loading

0 comments on commit 8cceb88

Please sign in to comment.