Skip to content
Open
2 changes: 2 additions & 0 deletions packages/javascript/bh-shared-ui/src/utils/object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,5 @@

/** Returns Object.entries with results retaining their types */
export const typedEntries = <T extends object>(obj: T): [keyof T, T[keyof T]][] => Object.entries(obj) as any;

export const noop = () => undefined;
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,40 @@ import { render, screen, waitFor, within } from '../../test-utils';

import UserProfile from './UserProfile';

import { ConfigurationKey } from 'js-client-library';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { QueryClient } from 'react-query';
import { configurationKeys } from '../../hooks';

const CONFIG_ENABLED_RESPONSE = {
data: [
{
key: ConfigurationKey.APITokens,
value: {
enabled: true,
},
},
],
};

const CONFIG_DISABLED_RESPONSE = {
data: [
{
key: ConfigurationKey.APITokens,
value: {
enabled: false,
},
},
],
};

const server = setupServer(
rest.get(`/api/v2/self`, (req, res) => {
return res();
}),
rest.get(`/api/v2/config`, async (_req, res, ctx) => {
return res(ctx.json(CONFIG_ENABLED_RESPONSE));
})
);

Expand Down Expand Up @@ -166,3 +194,26 @@ describe('UserProfile', () => {
});
});
});

describe('Api Keys', () => {
it('should display api key management button', async () => {
render(<UserProfile />);
const apiKeyManagementButton = await screen.findByRole('button', { name: 'API Key Management' });
expect(apiKeyManagementButton).toBeInTheDocument();
});

it('should not display api key management button', async () => {
server.use(
rest.get(`/api/v2/config`, async (_req, res, ctx) => {
return res(ctx.json(CONFIG_DISABLED_RESPONSE));
})
);

const queryClient = new QueryClient();
render(<UserProfile />, { queryClient });

await queryClient.invalidateQueries(configurationKeys.all);
const apiKeyManagementButton = screen.queryByRole('button', { name: 'API Key Management' });
expect(apiKeyManagementButton).not.toBeInTheDocument();
Comment on lines +212 to +217
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Stabilize the disabled-state assertion to avoid false positives.

At Line 216, absence is asserted immediately; this can pass before the async config query settles. Wait for configuration query completion first, then assert non-presence.

💡 Suggested test hardening
     const queryClient = new QueryClient();
     render(<UserProfile />, { queryClient });

-    await queryClient.invalidateQueries(configurationKeys.all);
-    const apiKeyManagementButton = screen.queryByRole('button', { name: 'API Key Management' });
-    expect(apiKeyManagementButton).not.toBeInTheDocument();
+    await waitFor(() =>
+        expect(queryClient.getQueryState(configurationKeys.all)?.status).toBe('success')
+    );
+    expect(screen.queryByRole('button', { name: 'API Key Management' })).not.toBeInTheDocument();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const queryClient = new QueryClient();
render(<UserProfile />, { queryClient });
await queryClient.invalidateQueries(configurationKeys.all);
const apiKeyManagementButton = screen.queryByRole('button', { name: 'API Key Management' });
expect(apiKeyManagementButton).not.toBeInTheDocument();
const queryClient = new QueryClient();
render(<UserProfile />, { queryClient });
await waitFor(() =>
expect(queryClient.getQueryState(configurationKeys.all)?.status).toBe('success')
);
expect(screen.queryByRole('button', { name: 'API Key Management' })).not.toBeInTheDocument();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/javascript/bh-shared-ui/src/views/UserProfile/UserProfile.test.tsx`
around lines 212 - 217, The test asserts the "API Key Management" button is
absent before the async configuration query settles; update the test around
QueryClient, UserProfile and configurationKeys.all to wait for the config query
to finish (e.g., use testing-library's waitFor or waitForElementToBeRemoved) and
then assert that screen.queryByRole('button', { name: 'API Key Management' }) is
not in the document; ensure the wait targets a stable indicator of query
completion (like waiting for QueryClient to stop fetching or for a known loading
indicator to disappear) before making the non-presence assertion.

});
});
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
TextWithFallback,
UserTokenManagementDialog,
} from '../../components';
import { useAPITokensConfiguration } from '../../hooks';
import { useSelf } from '../../hooks/useBloodHoundUsers';
import { useNotifications } from '../../providers';
import { apiClient, getUsername } from '../../utils';
Expand All @@ -45,6 +46,7 @@ const UserProfile = () => {
const [disable2FASecret, setDisable2FASecret] = useState('');

const getSelfQuery = useSelf();
const apiTokensEnabled = useAPITokensConfiguration();

const updateUserPasswordMutation = useMutation(
({ userId, ...payload }: { userId: string } & PutUserAuthSecretRequest) =>
Expand Down Expand Up @@ -131,19 +133,21 @@ const UserProfile = () => {
<Typography variant='h2'>Authentication</Typography>
</Box>
<Grid container spacing={2} alignItems='center'>
<Grid container item>
<Grid item xs={3}>
<Typography variant='body1'>API Key Management</Typography>
</Grid>
<Grid item xs={2}>
<Button
style={{ width: '100%' }}
onClick={() => setUserTokenManagementDialogOpen(true)}
data-testid='my-profile_button-api-key-management'>
API Key Management
</Button>
{apiTokensEnabled && (
<Grid container item>
<Grid item xs={3}>
<Typography variant='body1'>API Key Management</Typography>
</Grid>
<Grid item xs={2}>
<Button
style={{ width: '100%' }}
onClick={() => setUserTokenManagementDialogOpen(true)}
data-testid='my-profile_button-api-key-management'>
API Key Management
</Button>
</Grid>
</Grid>
</Grid>
)}
{user && user.sso_provider_id === null && (
<>
<Grid container item>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// Copyright 2026 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 userEvent from '@testing-library/user-event';
import { ConfigurationKey } from 'js-client-library';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { render, screen } from '../../test-utils';
import { noop } from '../../utils';
import UserActionsMenu from './UserActionsMenu';

const CONFIG_ENABLED_RESPONSE = {
data: [
{
key: ConfigurationKey.APITokens,
value: {
enabled: true,
},
},
],
};

const CONFIG_DISABLED_RESPONSE = {
data: [
{
key: ConfigurationKey.APITokens,
value: {
enabled: false,
},
},
],
};

type ComponentProps = React.ComponentProps<typeof UserActionsMenu>;

const server = setupServer(
rest.get(`/api/v2/config`, async (_req, res, ctx) => {
return res(ctx.json(CONFIG_ENABLED_RESPONSE));
})
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

describe('Api Keys', () => {
const setup = (
{
userId = '',
onOpen = noop,
showPasswordOptions = false,
showAuthMgmtButtons = false,
showDisableMfaButton = false,
userDisabled = false,
onUpdateUser = noop,
onDisableUser = noop,
onEnableUser = noop,
onDeleteUser = noop,
onUpdateUserPassword = noop,
onExpireUserPassword = noop,
onManageUserTokens = noop,
onDisableUserMfa = noop,
index = 0,
} = {} as ComponentProps
) => {
const user = userEvent.setup();

const screen = render(
<UserActionsMenu
userId={userId}
onOpen={onOpen}
showPasswordOptions={showPasswordOptions}
showAuthMgmtButtons={showAuthMgmtButtons}
showDisableMfaButton={showDisableMfaButton}
userDisabled={userDisabled}
onUpdateUser={onUpdateUser}
onDisableUser={onDisableUser}
onEnableUser={onEnableUser}
onDeleteUser={onDeleteUser}
onUpdateUserPassword={onUpdateUserPassword}
onExpireUserPassword={onExpireUserPassword}
onManageUserTokens={onManageUserTokens}
onDisableUserMfa={onDisableUserMfa}
index={index}
/>
);

return { screen, user };
};

it('should display generate/revoke api tokens button', async () => {
const { user } = setup();

const button = screen.getByRole('button', { name: /show user actions/i });
await user.click(button);

await screen.findByRole('menuitem', { name: /generate \/ revoke api tokens/i });
});

it('should not display generate/revoke api tokens button', async () => {
server.use(
rest.get(`/api/v2/config`, async (_req, res, ctx) => {
return res(ctx.json(CONFIG_DISABLED_RESPONSE));
})
);
const { user } = setup();

const button = screen.getByRole('button', { name: /show user actions/i });
await user.click(button);

const apiKeyManagementButton = screen.queryByRole('menuitem', { name: /generate \/ revoke api tokens/i });
expect(apiKeyManagementButton).not.toBeInTheDocument();
Comment on lines +112 to +124
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Make the disabled-path check deterministic after config fetch.

At Line 123, immediate queryByRole can pass before /api/v2/config resolves. Gate the assertion on observing the config request completion.

💡 Suggested test hardening
-import { render, screen } from '../../test-utils';
+import { render, screen, waitFor } from '../../test-utils';

 it('should not display generate/revoke api tokens button', async () => {
+    let configRequested = false;
     server.use(
         rest.get(`/api/v2/config`, async (_req, res, ctx) => {
+            configRequested = true;
             return res(ctx.json(CONFIG_DISABLED_RESPONSE));
         })
     );
     const { user } = setup();

     const button = screen.getByRole('button', { name: /show user actions/i });
     await user.click(button);

+    await waitFor(() => expect(configRequested).toBe(true));
     const apiKeyManagementButton = screen.queryByRole('menuitem', { name: /generate \/ revoke api tokens/i });
     expect(apiKeyManagementButton).not.toBeInTheDocument();
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('should not display generate/revoke api tokens button', async () => {
server.use(
rest.get(`/api/v2/config`, async (_req, res, ctx) => {
return res(ctx.json(CONFIG_DISABLED_RESPONSE));
})
);
const { user } = setup();
const button = screen.getByRole('button', { name: /show user actions/i });
await user.click(button);
const apiKeyManagementButton = screen.queryByRole('menuitem', { name: /generate \/ revoke api tokens/i });
expect(apiKeyManagementButton).not.toBeInTheDocument();
import { render, screen, waitFor } from '../../test-utils';
it('should not display generate/revoke api tokens button', async () => {
let configRequested = false;
server.use(
rest.get(`/api/v2/config`, async (_req, res, ctx) => {
configRequested = true;
return res(ctx.json(CONFIG_DISABLED_RESPONSE));
})
);
const { user } = setup();
const button = screen.getByRole('button', { name: /show user actions/i });
await user.click(button);
await waitFor(() => expect(configRequested).toBe(true));
const apiKeyManagementButton = screen.queryByRole('menuitem', { name: /generate \/ revoke api tokens/i });
expect(apiKeyManagementButton).not.toBeInTheDocument();
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/javascript/bh-shared-ui/src/views/Users/UserActionsMenu.test.tsx`
around lines 112 - 124, The test "should not display generate/revoke api tokens
button" races with the async /api/v2/config response; make the assertion
deterministic by awaiting the config fetch completion before checking for the
menu item. Update the test in UserActionsMenu.test.tsx (the it block with name
'should not display generate/revoke api tokens button') to wait for the
fetch—e.g. await screen.findBy... for an element rendered after config loads or
wrap the assertion in await waitFor(() => expect(screen.queryByRole('menuitem',
{ name: /generate \/ revoke api tokens/i })).not.toBeInTheDocument())—so the
query runs only after the mocked /api/v2/config handler has resolved.

});
});
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { IconButton, ListItemIcon, ListItemText, Menu, MenuItem, MenuProps } from '@mui/material';
import withStyles from '@mui/styles/withStyles';
import React from 'react';
import { useAPITokensConfiguration } from '../../hooks';

const StyledMenu = withStyles({
paper: {
Expand Down Expand Up @@ -87,6 +88,7 @@ const UserActionsMenu: React.FC<UserActionsMenuProps> = ({
/* Hooks */

const [anchorEl, setAnchorEl] = React.useState<HTMLElement | null>(null);
const apiTokensEnabled = useAPITokensConfiguration();

/* Event Handlers */

Expand Down Expand Up @@ -186,17 +188,18 @@ const UserActionsMenu: React.FC<UserActionsMenuProps> = ({
</MenuItem>
)}

<MenuItem
onClick={(e: React.MouseEvent<HTMLLIElement>) => {
onManageUserTokens(e);
setAnchorEl(null);
}}>
<ListItemIcon>
<FontAwesomeIcon icon={faCogs} />
</ListItemIcon>
<ListItemText primary='Generate / Revoke API Tokens' />
</MenuItem>

{apiTokensEnabled && (
<MenuItem
onClick={(e: React.MouseEvent<HTMLLIElement>) => {
onManageUserTokens(e);
setAnchorEl(null);
}}>
<ListItemIcon>
<FontAwesomeIcon icon={faCogs} />
</ListItemIcon>
<ListItemText primary='Generate / Revoke API Tokens' />
</MenuItem>
)}
{showDisableMfaButton && (
<MenuItem
onClick={(e: React.MouseEvent<HTMLLIElement>) => {
Expand Down
Loading