mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-14 06:00:30 +01:00
Compare commits
11 Commits
feat/enabl
...
platform-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d35db1b02b | ||
|
|
0556e67739 | ||
|
|
79ea20b371 | ||
|
|
2bbd11c181 | ||
|
|
0f9891d1c1 | ||
|
|
b236a29a99 | ||
|
|
828459ab30 | ||
|
|
b572e30045 | ||
|
|
f1ce804629 | ||
|
|
d15065b808 | ||
|
|
757c4e8ea9 |
@@ -190,7 +190,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.123.0
|
||||
image: signoz/signoz:v0.122.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
# - "6060:6060" # pprof port
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.123.0
|
||||
image: signoz/signoz:v0.122.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
volumes:
|
||||
|
||||
@@ -181,7 +181,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.123.0}
|
||||
image: signoz/signoz:${VERSION:-v0.122.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -109,7 +109,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.123.0}
|
||||
image: signoz/signoz:${VERSION:-v0.122.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -596,6 +596,7 @@ function CustomTimePicker({
|
||||
>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
autoComplete="off"
|
||||
className={cx(
|
||||
'timeSelection-input',
|
||||
inputStatus === CustomTimePickerInputStatus.ERROR ? 'error' : '',
|
||||
|
||||
@@ -103,7 +103,7 @@ function EditMemberDrawer({
|
||||
const { user: currentUser } = useAppContext();
|
||||
|
||||
const [localDisplayName, setLocalDisplayName] = useState('');
|
||||
const [localRole, setLocalRole] = useState('');
|
||||
const [localRoles, setLocalRoles] = useState<string[]>([]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveErrors, setSaveErrors] = useState<SaveError[]>([]);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
@@ -141,7 +141,7 @@ function EditMemberDrawer({
|
||||
} = useRoles();
|
||||
|
||||
const {
|
||||
fetchedRoleIds,
|
||||
currentRoles: currentMemberRoles,
|
||||
isLoading: isMemberRolesLoading,
|
||||
applyDiff,
|
||||
} = useMemberRoleManager(member?.id ?? '', open && !!member?.id);
|
||||
@@ -188,16 +188,24 @@ function EditMemberDrawer({
|
||||
if (!member?.id) {
|
||||
roleSessionRef.current = null;
|
||||
} else if (member.id !== roleSessionRef.current && !isMemberRolesLoading) {
|
||||
setLocalRole(fetchedRoleIds[0] ?? '');
|
||||
setLocalRoles(
|
||||
currentMemberRoles.map((r) => r.id).filter(Boolean) as string[],
|
||||
);
|
||||
roleSessionRef.current = member.id;
|
||||
}
|
||||
}, [member?.id, fetchedRoleIds, isMemberRolesLoading]);
|
||||
}, [member?.id, currentMemberRoles, isMemberRolesLoading]);
|
||||
|
||||
const isDirty =
|
||||
member !== null &&
|
||||
fetchedUser != null &&
|
||||
(localDisplayName !== fetchedDisplayName ||
|
||||
localRole !== (fetchedRoleIds[0] ?? ''));
|
||||
JSON.stringify([...localRoles].sort()) !==
|
||||
JSON.stringify(
|
||||
currentMemberRoles
|
||||
.map((r) => r.id)
|
||||
.filter(Boolean)
|
||||
.sort(),
|
||||
));
|
||||
|
||||
const { mutateAsync: updateMyUser } = useUpdateMyUserV2();
|
||||
const { mutateAsync: updateUser } = useUpdateUser();
|
||||
@@ -272,7 +280,14 @@ function EditMemberDrawer({
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const nameChanged = localDisplayName !== fetchedDisplayName;
|
||||
const rolesChanged = localRole !== (fetchedRoleIds[0] ?? '');
|
||||
const rolesChanged =
|
||||
JSON.stringify([...localRoles].sort()) !==
|
||||
JSON.stringify(
|
||||
currentMemberRoles
|
||||
.map((r) => r.id)
|
||||
.filter(Boolean)
|
||||
.sort(),
|
||||
);
|
||||
|
||||
const namePromise = nameChanged
|
||||
? isSelf
|
||||
@@ -286,7 +301,7 @@ function EditMemberDrawer({
|
||||
const [nameResult, rolesResult] = await Promise.allSettled([
|
||||
namePromise,
|
||||
rolesChanged
|
||||
? applyDiff([localRole].filter(Boolean), availableRoles)
|
||||
? applyDiff([...localRoles], availableRoles)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
@@ -305,10 +320,7 @@ function EditMemberDrawer({
|
||||
context: 'Roles update',
|
||||
apiError: toSaveApiError(rolesResult.reason),
|
||||
onRetry: async (): Promise<void> => {
|
||||
const failures = await applyDiff(
|
||||
[localRole].filter(Boolean),
|
||||
availableRoles,
|
||||
);
|
||||
const failures = await applyDiff([...localRoles], availableRoles);
|
||||
setSaveErrors((prev) => {
|
||||
const rest = prev.filter((e) => e.context !== 'Roles update');
|
||||
return [
|
||||
@@ -353,9 +365,9 @@ function EditMemberDrawer({
|
||||
isDirty,
|
||||
isSelf,
|
||||
localDisplayName,
|
||||
localRole,
|
||||
localRoles,
|
||||
fetchedDisplayName,
|
||||
fetchedRoleIds,
|
||||
currentMemberRoles,
|
||||
updateMyUser,
|
||||
updateUser,
|
||||
applyDiff,
|
||||
@@ -503,10 +515,15 @@ function EditMemberDrawer({
|
||||
>
|
||||
<div className="edit-member-drawer__input-wrapper edit-member-drawer__input-wrapper--disabled">
|
||||
<div className="edit-member-drawer__disabled-roles">
|
||||
{localRole ? (
|
||||
<Badge color="vanilla">
|
||||
{availableRoles.find((r) => r.id === localRole)?.name ?? localRole}
|
||||
</Badge>
|
||||
{localRoles.length > 0 ? (
|
||||
localRoles.map((roleId) => {
|
||||
const role = availableRoles.find((r) => r.id === roleId);
|
||||
return (
|
||||
<Badge key={roleId} color="vanilla">
|
||||
{role?.name ?? roleId}
|
||||
</Badge>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<span className="edit-member-drawer__email-text">—</span>
|
||||
)}
|
||||
@@ -517,14 +534,15 @@ function EditMemberDrawer({
|
||||
) : (
|
||||
<RolesSelect
|
||||
id="member-role"
|
||||
mode="multiple"
|
||||
roles={availableRoles}
|
||||
loading={rolesLoading}
|
||||
isError={rolesError}
|
||||
error={rolesErrorObj}
|
||||
onRefetch={refetchRoles}
|
||||
value={localRole}
|
||||
onChange={(role): void => {
|
||||
setLocalRole(role ?? '');
|
||||
value={localRoles}
|
||||
onChange={(roles): void => {
|
||||
setLocalRoles(roles);
|
||||
setSaveErrors((prev) =>
|
||||
prev.filter(
|
||||
(err) =>
|
||||
@@ -532,8 +550,7 @@ function EditMemberDrawer({
|
||||
),
|
||||
);
|
||||
}}
|
||||
placeholder="Select role"
|
||||
allowClear={false}
|
||||
placeholder="Select roles"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
useCreateResetPasswordToken,
|
||||
useDeleteUser,
|
||||
useGetResetPasswordToken,
|
||||
useGetRolesByUserID,
|
||||
useGetUser,
|
||||
useRemoveUserRoleByUserIDAndRoleID,
|
||||
useSetRoleByUserID,
|
||||
useUpdateMyUserV2,
|
||||
useUpdateUser,
|
||||
@@ -23,11 +25,16 @@ import EditMemberDrawer, { EditMemberDrawerProps } from '../EditMemberDrawer';
|
||||
jest.mock('api/generated/services/users', () => ({
|
||||
useDeleteUser: jest.fn(),
|
||||
useGetUser: jest.fn(),
|
||||
useGetRolesByUserID: jest.fn(),
|
||||
useRemoveUserRoleByUserIDAndRoleID: jest.fn(),
|
||||
useUpdateUser: jest.fn(),
|
||||
useUpdateMyUserV2: jest.fn(),
|
||||
useSetRoleByUserID: jest.fn(),
|
||||
useGetResetPasswordToken: jest.fn(),
|
||||
useCreateResetPasswordToken: jest.fn(),
|
||||
getGetRolesByUserIDQueryKey: ({ id }: { id: string }): string[] => [
|
||||
`/api/v2/users/${id}/roles`,
|
||||
],
|
||||
}));
|
||||
|
||||
jest.mock('api/ErrorResponseHandlerForGeneratedAPIs', () => ({
|
||||
@@ -98,6 +105,7 @@ jest.mock('react-use', () => ({
|
||||
const ROLES_ENDPOINT = '*/api/v1/roles';
|
||||
|
||||
const mockDeleteMutate = jest.fn();
|
||||
const mockRemoveMutateAsync = jest.fn();
|
||||
const mockCreateTokenMutateAsync = jest.fn();
|
||||
|
||||
const showErrorModal = jest.fn();
|
||||
@@ -186,6 +194,14 @@ describe('EditMemberDrawer', () => {
|
||||
isLoading: false,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
(useGetRolesByUserID as jest.Mock).mockReturnValue({
|
||||
data: { data: [managedRoles[0]] },
|
||||
isLoading: false,
|
||||
});
|
||||
(useRemoveUserRoleByUserIDAndRoleID as jest.Mock).mockReturnValue({
|
||||
mutateAsync: mockRemoveMutateAsync.mockResolvedValue({}),
|
||||
isLoading: false,
|
||||
});
|
||||
(useUpdateUser as jest.Mock).mockReturnValue({
|
||||
mutateAsync: jest.fn().mockResolvedValue({}),
|
||||
isLoading: false,
|
||||
@@ -296,7 +312,7 @@ describe('EditMemberDrawer', () => {
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('selecting a different role calls setRole with the new role name', async () => {
|
||||
it('adding a new role calls setRole without removing existing ones', async () => {
|
||||
const onComplete = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const mockSet = jest.fn().mockResolvedValue({});
|
||||
@@ -308,7 +324,7 @@ describe('EditMemberDrawer', () => {
|
||||
|
||||
renderDrawer({ onComplete });
|
||||
|
||||
// Open the roles dropdown and select signoz-editor
|
||||
// signoz-admin is already selected; add signoz-editor on top
|
||||
await user.click(screen.getByLabelText('Roles'));
|
||||
await user.click(await screen.findByTitle('signoz-editor'));
|
||||
|
||||
@@ -321,34 +337,31 @@ describe('EditMemberDrawer', () => {
|
||||
pathParams: { id: 'user-1' },
|
||||
data: { name: 'signoz-editor' },
|
||||
});
|
||||
expect(mockRemoveMutateAsync).not.toHaveBeenCalled();
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call removeRole when the role is changed', async () => {
|
||||
it('deselecting a role calls removeRole with the role id', async () => {
|
||||
const onComplete = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const mockSet = jest.fn().mockResolvedValue({});
|
||||
|
||||
(useSetRoleByUserID as jest.Mock).mockReturnValue({
|
||||
mutateAsync: mockSet,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
renderDrawer({ onComplete });
|
||||
|
||||
// Switch from signoz-admin to signoz-viewer using single-select
|
||||
await user.click(screen.getByLabelText('Roles'));
|
||||
await user.click(await screen.findByTitle('signoz-viewer'));
|
||||
// signoz-admin appears as a selected tag — click its remove button to deselect
|
||||
const adminTag = await screen.findByTitle('signoz-admin');
|
||||
const removeBtn = adminTag.querySelector(
|
||||
'.ant-select-selection-item-remove',
|
||||
) as Element;
|
||||
await user.click(removeBtn);
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /save member details/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSet).toHaveBeenCalledWith({
|
||||
pathParams: { id: 'user-1' },
|
||||
data: { name: 'signoz-viewer' },
|
||||
expect(mockRemoveMutateAsync).toHaveBeenCalledWith({
|
||||
pathParams: { id: 'user-1', roleId: managedRoles[0].id },
|
||||
});
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { GetHosts200 } from 'api/generated/services/sigNoz.schemas';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import CustomDomainSettings from '../CustomDomainSettings';
|
||||
|
||||
@@ -44,18 +44,20 @@ const mockHostsResponse: GetHosts200 = {
|
||||
};
|
||||
|
||||
describe('CustomDomainSettings', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
mockToastCustom.mockClear();
|
||||
});
|
||||
|
||||
it('renders active host URL in the trigger button', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
// The active host is the non-default one (custom-host)
|
||||
@@ -63,20 +65,11 @@ describe('CustomDomainSettings', () => {
|
||||
});
|
||||
|
||||
it('opens edit modal when clicking the edit button', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /edit workspace link/i }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit workspace link/i }));
|
||||
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: /edit workspace link/i }),
|
||||
@@ -89,28 +82,20 @@ describe('CustomDomainSettings', () => {
|
||||
let capturedBody: Record<string, unknown> = {};
|
||||
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
rest.put(ZEUS_HOSTS_ENDPOINT, async (req, res, ctx) => {
|
||||
capturedBody = await req.json<Record<string, unknown>>();
|
||||
return res(ctx.status(200), ctx.json({}));
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /edit workspace link/i }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit workspace link/i }));
|
||||
|
||||
// The input is inside the modal — find it by its role
|
||||
const input = screen.getByRole('textbox');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'myteam');
|
||||
await user.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||
fireEvent.change(input, { target: { value: 'myteam' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedBody).toStrictEqual({ name: 'myteam' });
|
||||
@@ -119,9 +104,6 @@ describe('CustomDomainSettings', () => {
|
||||
|
||||
it('shows contact support option when domain update returns 409', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
rest.put(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(409),
|
||||
@@ -130,18 +112,14 @@ describe('CustomDomainSettings', () => {
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /edit workspace link/i }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit workspace link/i }));
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'myteam');
|
||||
await user.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||
fireEvent.change(input, { target: { value: 'myteam' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||
|
||||
await expect(
|
||||
screen.findByRole('button', { name: /contact support/i }),
|
||||
@@ -149,24 +127,14 @@ describe('CustomDomainSettings', () => {
|
||||
});
|
||||
|
||||
it('shows validation error when subdomain is less than 3 characters', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /edit workspace link/i }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit workspace link/i }));
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'ab');
|
||||
await user.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||
fireEvent.change(input, { target: { value: 'ab' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||
|
||||
expect(
|
||||
screen.getByText(/minimum 3 characters required/i),
|
||||
@@ -174,19 +142,12 @@ describe('CustomDomainSettings', () => {
|
||||
});
|
||||
|
||||
it('shows all workspace URLs as links in the dropdown', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
|
||||
// Open the URL dropdown
|
||||
await user.click(
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: /custom-host\.test\.cloud/i }),
|
||||
);
|
||||
|
||||
@@ -207,26 +168,19 @@ describe('CustomDomainSettings', () => {
|
||||
|
||||
it('calls toast.custom with new URL after successful domain update', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
rest.put(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({})),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /edit workspace link/i }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit workspace link/i }));
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'myteam');
|
||||
await user.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||
fireEvent.change(input, { target: { value: 'myteam' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||
|
||||
// Verify toast.custom was called
|
||||
await waitFor(() => {
|
||||
@@ -243,12 +197,6 @@ describe('CustomDomainSettings', () => {
|
||||
|
||||
describe('Workspace Name rendering', () => {
|
||||
it('renders org displayName when available from appContext', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<CustomDomainSettings />, undefined, {
|
||||
appContextOverrides: {
|
||||
org: [{ id: 'xyz', displayName: 'My Org Name', createdAt: 0 }],
|
||||
@@ -259,12 +207,6 @@ describe('CustomDomainSettings', () => {
|
||||
});
|
||||
|
||||
it('falls back to customDomainSubdomain when org displayName is missing', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<CustomDomainSettings />, undefined, {
|
||||
appContextOverrides: { org: [] },
|
||||
});
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import setRetentionApiV2 from 'api/settings/setRetentionV2';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
import { IDiskType } from 'types/api/disks/getDisks';
|
||||
import {
|
||||
PayloadPropsLogs,
|
||||
@@ -115,8 +109,6 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
|
||||
describe('Test 1: S3 Enabled - Only Days in Dropdown', () => {
|
||||
it('should show only Days option for S3 retention and send correct API payload', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<GeneralSettings
|
||||
metricsTtlValuesPayload={mockMetricsRetention}
|
||||
@@ -159,8 +151,7 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
fireEvent.click(document.body);
|
||||
|
||||
// Change S3 retention value to 5 days
|
||||
await user.clear(s3Input);
|
||||
await user.type(s3Input, '5');
|
||||
fireEvent.change(s3Input, { target: { value: '5' } });
|
||||
|
||||
// Find the save button in the Logs row
|
||||
const saveButton = logsRow.querySelector(
|
||||
@@ -217,8 +208,6 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
|
||||
describe('Test 2: S3 Disabled - Field Hidden', () => {
|
||||
it('should hide S3 retention field and send empty S3 values to API', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<GeneralSettings
|
||||
metricsTtlValuesPayload={mockMetricsRetention}
|
||||
@@ -245,7 +234,7 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
const totalDropdown = logsRow.querySelector(
|
||||
'.ant-select-selector',
|
||||
) as HTMLElement;
|
||||
await user.click(totalDropdown);
|
||||
fireEvent.mouseDown(totalDropdown);
|
||||
|
||||
// Wait for dropdown options to appear
|
||||
await waitFor(() => {
|
||||
@@ -259,11 +248,10 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
opt.textContent?.includes('Days'),
|
||||
);
|
||||
expect(daysOption).toBeInTheDocument();
|
||||
await user.click(daysOption as HTMLElement);
|
||||
fireEvent.click(daysOption as HTMLElement);
|
||||
|
||||
// Now change the value
|
||||
await user.clear(totalInput);
|
||||
await user.type(totalInput, '60');
|
||||
fireEvent.change(totalInput, { target: { value: '60' } });
|
||||
|
||||
// Find the save button
|
||||
const saveButton = logsRow.querySelector(
|
||||
@@ -277,14 +265,14 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
});
|
||||
|
||||
// Click save button
|
||||
await user.click(saveButton);
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
// Wait for modal to appear
|
||||
const okButton = await screen.findByRole('button', { name: /ok/i });
|
||||
expect(okButton).toBeInTheDocument();
|
||||
|
||||
// Click OK button
|
||||
await user.click(okButton);
|
||||
fireEvent.click(okButton);
|
||||
|
||||
// Verify API was called with empty S3 values (60 days)
|
||||
await waitFor(() => {
|
||||
@@ -333,8 +321,6 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
|
||||
describe('Test 4: Save Button State with S3 Disabled', () => {
|
||||
it('should disable save button when cold_storage_ttl_days is -1 and no changes made', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<GeneralSettings
|
||||
metricsTtlValuesPayload={mockMetricsRetention}
|
||||
@@ -365,8 +351,7 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
const totalInput = inputs[0] as HTMLInputElement;
|
||||
|
||||
// Change total retention value to trigger button enable
|
||||
await user.clear(totalInput);
|
||||
await user.type(totalInput, '60');
|
||||
fireEvent.change(totalInput, { target: { value: '60' } });
|
||||
|
||||
// Button should now be enabled after change
|
||||
await waitFor(() => {
|
||||
@@ -374,8 +359,7 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
});
|
||||
|
||||
// Revert to original value (30 days displays as 1 Month)
|
||||
await user.clear(totalInput);
|
||||
await user.type(totalInput, '1');
|
||||
fireEvent.change(totalInput, { target: { value: '1' } });
|
||||
|
||||
// Button should be disabled again (back to original state)
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Dispatch, SetStateAction, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { patchRulePartial } from 'api/alerts/patchRulePartial';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { invalidateGetRuleByID } from 'api/generated/services/rules';
|
||||
import type {
|
||||
RenderErrorResponseDTO,
|
||||
RuletypesRuleDTO,
|
||||
@@ -28,6 +30,7 @@ function ToggleAlertState({
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const onToggleHandler = async (
|
||||
id: string,
|
||||
@@ -60,6 +63,9 @@ function ToggleAlertState({
|
||||
loading: false,
|
||||
payload: updatedRule,
|
||||
}));
|
||||
|
||||
invalidateGetRuleByID(queryClient, { id });
|
||||
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TypesUserDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
|
||||
import MembersSettings from '../MembersSettings';
|
||||
|
||||
@@ -76,32 +76,27 @@ describe('MembersSettings (integration)', () => {
|
||||
});
|
||||
|
||||
it('filters to pending invites via the filter dropdown', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<MembersSettings />);
|
||||
|
||||
await screen.findByText('Alice Smith');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /all members/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /all members/i }));
|
||||
|
||||
const pendingOption = await screen.findByText(/pending invites/i);
|
||||
await user.click(pendingOption);
|
||||
fireEvent.click(pendingOption);
|
||||
|
||||
await screen.findByText('charlie@signoz.io');
|
||||
expect(screen.queryByText('Alice Smith')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters members by name using the search input', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<MembersSettings />);
|
||||
|
||||
await screen.findByText('Alice Smith');
|
||||
|
||||
await user.type(
|
||||
screen.getByPlaceholderText(/Search by name or email/i),
|
||||
'bob',
|
||||
);
|
||||
fireEvent.change(screen.getByPlaceholderText(/Search by name or email/i), {
|
||||
target: { value: 'bob' },
|
||||
});
|
||||
|
||||
await screen.findByText('Bob Jones');
|
||||
expect(screen.queryByText('Alice Smith')).not.toBeInTheDocument();
|
||||
@@ -109,31 +104,25 @@ describe('MembersSettings (integration)', () => {
|
||||
});
|
||||
|
||||
it('opens EditMemberDrawer when an active member row is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<MembersSettings />);
|
||||
|
||||
await user.click(await screen.findByText('Alice Smith'));
|
||||
fireEvent.click(await screen.findByText('Alice Smith'));
|
||||
|
||||
await screen.findByText('Member Details');
|
||||
});
|
||||
|
||||
it('opens EditMemberDrawer when a deleted member row is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<MembersSettings />);
|
||||
|
||||
await user.click(await screen.findByText('Dave Deleted'));
|
||||
fireEvent.click(await screen.findByText('Dave Deleted'));
|
||||
|
||||
expect(screen.queryByText('Member Details')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens InviteMembersModal when "Invite member" button is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<MembersSettings />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /invite member/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /invite member/i }));
|
||||
|
||||
await expect(
|
||||
screen.findAllByPlaceholderText('john@signoz.io'),
|
||||
|
||||
@@ -117,8 +117,7 @@ describe('CreateEdit Modal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Todo: to fixed properly - failing with - due to timeout > 5000ms
|
||||
describe.skip('Form Validation', () => {
|
||||
describe('Form Validation', () => {
|
||||
it('shows validation error when submitting without required fields', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
@@ -127,7 +126,7 @@ describe('CreateEdit Modal', () => {
|
||||
const configureButtons = await screen.findAllByRole('button', {
|
||||
name: /configure/i,
|
||||
});
|
||||
await user.click(configureButtons[0]);
|
||||
fireEvent.click(configureButtons[0]);
|
||||
|
||||
const saveButton = await screen.findByRole('button', {
|
||||
name: /save changes/i,
|
||||
@@ -338,11 +337,8 @@ describe('CreateEdit Modal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Todo: to fixed properly - failing with - due to timeout > 5000ms
|
||||
describe.skip('Modal Actions', () => {
|
||||
it('calls onClose when cancel button is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
describe('Modal Actions', () => {
|
||||
it('calls onClose when cancel button is clicked', () => {
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
@@ -352,7 +348,7 @@ describe('CreateEdit Modal', () => {
|
||||
);
|
||||
|
||||
const cancelButton = screen.getByRole('button', { name: /cancel/i });
|
||||
await user.click(cancelButton);
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
// Ungate feature flag for all tests in this file
|
||||
jest.mock('../../config', () => ({ IS_ROLE_DETAILS_AND_CRUD_ENABLED: true }));
|
||||
|
||||
import * as roleApi from 'api/generated/services/role';
|
||||
import {
|
||||
customRoleResponse,
|
||||
managedRoleResponse,
|
||||
} from 'mocks-server/__mockdata__/roles';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
within,
|
||||
} from 'tests/test-utils';
|
||||
|
||||
import RoleDetailsPage from '../RoleDetailsPage';
|
||||
|
||||
@@ -22,7 +29,7 @@ const allScopeObjectsResponse = {
|
||||
status: 'success',
|
||||
data: [
|
||||
{
|
||||
resource: { name: 'dashboard', type: 'dashboard' },
|
||||
resource: { kind: 'role', type: 'metaresources' },
|
||||
selectors: ['*'],
|
||||
},
|
||||
],
|
||||
@@ -44,8 +51,7 @@ afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
// Todo: to fixed properly - failing with - due to timeout > 5000ms
|
||||
describe.skip('RoleDetailsPage', () => {
|
||||
describe('RoleDetailsPage', () => {
|
||||
it('renders custom role header, tabs, description, permissions, and action buttons', async () => {
|
||||
setupDefaultHandlers();
|
||||
|
||||
@@ -57,20 +63,16 @@ describe.skip('RoleDetailsPage', () => {
|
||||
screen.findByText('Role — billing-manager'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
// Tab navigation
|
||||
expect(screen.getByText('Overview')).toBeInTheDocument();
|
||||
expect(screen.getByText('Members')).toBeInTheDocument();
|
||||
|
||||
// Role description (OverviewTab)
|
||||
expect(
|
||||
screen.getByText('Custom role for managing billing and invoices.'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Permission items derived from mocked authz relations
|
||||
expect(screen.getByText('Create')).toBeInTheDocument();
|
||||
expect(screen.getByText('Read')).toBeInTheDocument();
|
||||
|
||||
// Action buttons present for custom role
|
||||
expect(
|
||||
screen.getByRole('button', { name: /edit role details/i }),
|
||||
).toBeInTheDocument();
|
||||
@@ -96,14 +98,13 @@ describe.skip('RoleDetailsPage', () => {
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Action buttons absent for managed role
|
||||
expect(screen.queryByText('Edit Role Details')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /delete role/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('edit flow: modal opens pre-filled and calls PATCH on save and verify', async () => {
|
||||
it('edit flow: modal opens pre-filled and calls PATCH on save', async () => {
|
||||
const patchSpy = jest.fn();
|
||||
let description = customRoleResponse.data.description;
|
||||
server.use(
|
||||
@@ -138,21 +139,16 @@ describe.skip('RoleDetailsPage', () => {
|
||||
|
||||
await screen.findByText('Role — billing-manager');
|
||||
|
||||
// Open the edit modal
|
||||
await user.click(screen.getByRole('button', { name: /edit role details/i }));
|
||||
await expect(
|
||||
screen.findByText('Edit Role Details', {
|
||||
selector: '.ant-modal-title',
|
||||
}),
|
||||
screen.findByText('Edit Role Details', { selector: '.ant-modal-title' }),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
// Name field is disabled in edit mode (role rename is not allowed)
|
||||
const nameInput = screen.getByPlaceholderText(
|
||||
'Enter role name e.g. : Service Owner',
|
||||
);
|
||||
expect(nameInput).toBeDisabled();
|
||||
|
||||
// Update description and save
|
||||
const descField = screen.getByPlaceholderText(
|
||||
'A helpful description of the role',
|
||||
);
|
||||
@@ -168,9 +164,7 @@ describe.skip('RoleDetailsPage', () => {
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByText('Edit Role Details', {
|
||||
selector: '.ant-modal-title',
|
||||
}),
|
||||
screen.queryByText('Edit Role Details', { selector: '.ant-modal-title' }),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
@@ -219,58 +213,61 @@ describe.skip('RoleDetailsPage', () => {
|
||||
});
|
||||
|
||||
describe('permission side panel', () => {
|
||||
async function openCreatePanel(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
): Promise<void> {
|
||||
beforeEach(() => {
|
||||
// Both hooks mocked so data renders synchronously — no React Query scheduler or MSW round-trip.
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: customRoleResponse,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
jest
|
||||
.spyOn(roleApi, 'useGetObjects')
|
||||
.mockReturnValue({ data: emptyObjectsResponse, isLoading: false } as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function openCreatePanel(): Promise<HTMLElement> {
|
||||
await screen.findByText('Role — billing-manager');
|
||||
await user.click(screen.getByText('Create'));
|
||||
fireEvent.click(screen.getByText('Create'));
|
||||
await screen.findByText('Edit Create Permissions');
|
||||
await screen.findByRole('button', { name: /dashboard/i });
|
||||
const panel = document.querySelector(
|
||||
'.permission-side-panel',
|
||||
) as HTMLElement;
|
||||
await within(panel).findByRole('button', { name: 'Role' });
|
||||
return panel;
|
||||
}
|
||||
|
||||
it('Save Changes is disabled until a resource scope is changed', async () => {
|
||||
setupDefaultHandlers();
|
||||
server.use(
|
||||
rest.get(
|
||||
`${rolesApiBase}/:id/relation/:relation/objects`,
|
||||
(_req, res, ctx) => res(ctx.status(200), ctx.json(emptyObjectsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<RoleDetailsPage />, undefined, {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
});
|
||||
|
||||
await openCreatePanel(user);
|
||||
|
||||
// No change yet — config matches initial, unsavedCount = 0
|
||||
expect(screen.getByRole('button', { name: /save changes/i })).toBeDisabled();
|
||||
|
||||
// Expand Dashboard and flip to All — now Save is enabled
|
||||
await user.click(screen.getByRole('button', { name: /dashboard/i }));
|
||||
await user.click(screen.getByText('All'));
|
||||
const panel = await openCreatePanel();
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save changes/i }),
|
||||
).not.toBeDisabled();
|
||||
within(panel).getByRole('button', { name: /save changes/i }),
|
||||
).toBeDisabled();
|
||||
|
||||
// check for what shown now - unsavedCount = 1
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'Role' }));
|
||||
fireEvent.click(screen.getByText('All'));
|
||||
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: /save changes/i }),
|
||||
).not.toBeDisabled();
|
||||
expect(screen.getByText('1 unsaved change')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('set scope to All → patchObjects additions: ["*"], deletions: null', async () => {
|
||||
const patchSpy = jest.fn();
|
||||
|
||||
setupDefaultHandlers();
|
||||
server.use(
|
||||
rest.get(
|
||||
`${rolesApiBase}/:id/relation/:relation/objects`,
|
||||
(_req, res, ctx) => res(ctx.status(200), ctx.json(emptyObjectsResponse)),
|
||||
),
|
||||
rest.patch(
|
||||
`${rolesApiBase}/:id/relation/:relation/objects`,
|
||||
`${rolesApiBase}/:id/relations/:relation/objects`,
|
||||
async (req, res, ctx) => {
|
||||
patchSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: null }));
|
||||
@@ -278,23 +275,23 @@ describe.skip('RoleDetailsPage', () => {
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<RoleDetailsPage />, undefined, {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
});
|
||||
|
||||
await openCreatePanel(user);
|
||||
const panel = await openCreatePanel();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /dashboard/i }));
|
||||
await user.click(screen.getByText('All'));
|
||||
await user.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'Role' }));
|
||||
fireEvent.click(screen.getByText('All'));
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: /save changes/i }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(patchSpy).toHaveBeenCalledWith({
|
||||
additions: [
|
||||
{
|
||||
resource: { name: 'dashboard', type: 'dashboard' },
|
||||
resource: { kind: 'role', type: 'metaresources' },
|
||||
selectors: ['*'],
|
||||
},
|
||||
],
|
||||
@@ -306,14 +303,9 @@ describe.skip('RoleDetailsPage', () => {
|
||||
it('set scope to Only selected with IDs → patchObjects additions contain those IDs', async () => {
|
||||
const patchSpy = jest.fn();
|
||||
|
||||
setupDefaultHandlers();
|
||||
server.use(
|
||||
rest.get(
|
||||
`${rolesApiBase}/:id/relation/:relation/objects`,
|
||||
(_req, res, ctx) => res(ctx.status(200), ctx.json(emptyObjectsResponse)),
|
||||
),
|
||||
rest.patch(
|
||||
`${rolesApiBase}/:id/relation/:relation/objects`,
|
||||
`${rolesApiBase}/:id/relations/:relation/objects`,
|
||||
async (req, res, ctx) => {
|
||||
patchSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: null }));
|
||||
@@ -327,23 +319,25 @@ describe.skip('RoleDetailsPage', () => {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
});
|
||||
|
||||
await openCreatePanel(user);
|
||||
const panel = await openCreatePanel();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /dashboard/i }));
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'Role' }));
|
||||
|
||||
const combobox = screen.getByRole('combobox');
|
||||
await user.click(combobox);
|
||||
await user.type(combobox, 'dash-1');
|
||||
const combobox = within(panel).getByRole('combobox');
|
||||
await user.type(combobox, 'role-001');
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
// user.click waits for the tag-addition state to settle before clicking.
|
||||
await user.click(
|
||||
within(panel).getByRole('button', { name: /save changes/i }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(patchSpy).toHaveBeenCalledWith({
|
||||
additions: [
|
||||
{
|
||||
resource: { name: 'dashboard', type: 'dashboard' },
|
||||
selectors: ['dash-1'],
|
||||
resource: { kind: 'role', type: 'metaresources' },
|
||||
selectors: ['role-001'],
|
||||
},
|
||||
],
|
||||
deletions: null,
|
||||
@@ -354,15 +348,13 @@ describe.skip('RoleDetailsPage', () => {
|
||||
it('existing All scope changed to Only selected (empty) → patchObjects deletions: ["*"], additions: null', async () => {
|
||||
const patchSpy = jest.fn();
|
||||
|
||||
setupDefaultHandlers();
|
||||
jest.spyOn(roleApi, 'useGetObjects').mockReturnValue({
|
||||
data: allScopeObjectsResponse,
|
||||
isLoading: false,
|
||||
} as any);
|
||||
server.use(
|
||||
rest.get(
|
||||
`${rolesApiBase}/:id/relation/:relation/objects`,
|
||||
(_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(allScopeObjectsResponse)),
|
||||
),
|
||||
rest.patch(
|
||||
`${rolesApiBase}/:id/relation/:relation/objects`,
|
||||
`${rolesApiBase}/:id/relations/:relation/objects`,
|
||||
async (req, res, ctx) => {
|
||||
patchSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: null }));
|
||||
@@ -370,26 +362,24 @@ describe.skip('RoleDetailsPage', () => {
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<RoleDetailsPage />, undefined, {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
});
|
||||
|
||||
await openCreatePanel(user);
|
||||
const panel = await openCreatePanel();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /dashboard/i }));
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'Role' }));
|
||||
fireEvent.click(screen.getByText('Only selected'));
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: /save changes/i }),
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Only selected'));
|
||||
await user.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
|
||||
// Should delete the '*' selector and add nothing
|
||||
await waitFor(() =>
|
||||
expect(patchSpy).toHaveBeenCalledWith({
|
||||
additions: null,
|
||||
deletions: [
|
||||
{
|
||||
resource: { name: 'dashboard', type: 'dashboard' },
|
||||
resource: { kind: 'role', type: 'metaresources' },
|
||||
selectors: ['*'],
|
||||
},
|
||||
],
|
||||
@@ -398,36 +388,25 @@ describe.skip('RoleDetailsPage', () => {
|
||||
});
|
||||
|
||||
it('unsaved changes counter shown on scope change, Discard resets it', async () => {
|
||||
setupDefaultHandlers();
|
||||
server.use(
|
||||
rest.get(
|
||||
`${rolesApiBase}/:id/relation/:relation/objects`,
|
||||
(_req, res, ctx) => res(ctx.status(200), ctx.json(emptyObjectsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<RoleDetailsPage />, undefined, {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
});
|
||||
|
||||
await openCreatePanel(user);
|
||||
const panel = await openCreatePanel();
|
||||
|
||||
// No unsaved changes indicator yet
|
||||
expect(screen.queryByText(/unsaved change/)).not.toBeInTheDocument();
|
||||
|
||||
// Change dashboard scope to "All"
|
||||
await user.click(screen.getByRole('button', { name: /dashboard/i }));
|
||||
await user.click(screen.getByText('All'));
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'Role' }));
|
||||
fireEvent.click(screen.getByText('All'));
|
||||
|
||||
expect(screen.getByText('1 unsaved change')).toBeInTheDocument();
|
||||
|
||||
// Discard reverts to initial config — counter disappears, Save re-disabled
|
||||
await user.click(screen.getByRole('button', { name: /discard/i }));
|
||||
fireEvent.click(within(panel).getByRole('button', { name: /discard/i }));
|
||||
|
||||
expect(screen.queryByText(/unsaved change/)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /save changes/i })).toBeDisabled();
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: /save changes/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,24 +4,27 @@ import {
|
||||
} from 'mocks-server/__mockdata__/roles';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
|
||||
import RolesSettings from '../RolesSettings';
|
||||
|
||||
const rolesApiURL = 'http://localhost/api/v1/roles';
|
||||
|
||||
describe('RolesSettings', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the header and search input', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get(rolesApiURL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders the header and search input', () => {
|
||||
render(<RolesSettings />);
|
||||
|
||||
expect(screen.getByText('Roles')).toBeInTheDocument();
|
||||
@@ -34,12 +37,6 @@ describe('RolesSettings', () => {
|
||||
});
|
||||
|
||||
it('displays roles grouped by managed and custom sections', async () => {
|
||||
server.use(
|
||||
rest.get(rolesApiURL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<RolesSettings />);
|
||||
|
||||
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
|
||||
@@ -68,20 +65,13 @@ describe('RolesSettings', () => {
|
||||
});
|
||||
|
||||
it('filters roles by search query on name', async () => {
|
||||
server.use(
|
||||
rest.get(rolesApiURL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<RolesSettings />);
|
||||
|
||||
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const searchInput = screen.getByPlaceholderText('Search for roles...');
|
||||
|
||||
await user.type(searchInput, 'billing');
|
||||
fireEvent.change(screen.getByPlaceholderText('Search for roles...'), {
|
||||
target: { value: 'billing' },
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByText('billing-manager'),
|
||||
@@ -92,20 +82,13 @@ describe('RolesSettings', () => {
|
||||
});
|
||||
|
||||
it('filters roles by search query on description', async () => {
|
||||
server.use(
|
||||
rest.get(rolesApiURL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<RolesSettings />);
|
||||
|
||||
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const searchInput = screen.getByPlaceholderText('Search for roles...');
|
||||
|
||||
await user.type(searchInput, 'read-only');
|
||||
fireEvent.change(screen.getByPlaceholderText('Search for roles...'), {
|
||||
target: { value: 'read-only' },
|
||||
});
|
||||
|
||||
await expect(screen.findByText('signoz-viewer')).resolves.toBeInTheDocument();
|
||||
expect(screen.queryByText('signoz-admin')).not.toBeInTheDocument();
|
||||
@@ -113,20 +96,13 @@ describe('RolesSettings', () => {
|
||||
});
|
||||
|
||||
it('shows empty state when search matches nothing', async () => {
|
||||
server.use(
|
||||
rest.get(rolesApiURL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<RolesSettings />);
|
||||
|
||||
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const searchInput = screen.getByPlaceholderText('Search for roles...');
|
||||
|
||||
await user.type(searchInput, 'nonexistentrole');
|
||||
fireEvent.change(screen.getByPlaceholderText('Search for roles...'), {
|
||||
target: { value: 'nonexistentrole' },
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByText('No roles match your search.'),
|
||||
@@ -183,12 +159,6 @@ describe('RolesSettings', () => {
|
||||
});
|
||||
|
||||
it('renders descriptions for all roles', async () => {
|
||||
server.use(
|
||||
rest.get(rolesApiURL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<RolesSettings />);
|
||||
|
||||
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react';
|
||||
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import ServiceAccountsSettings from '../ServiceAccountsSettings';
|
||||
|
||||
@@ -123,8 +123,6 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
});
|
||||
|
||||
it('filter dropdown to "Active" hides DISABLED accounts', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<NuqsTestingAdapter>
|
||||
<ServiceAccountsSettings />
|
||||
@@ -133,18 +131,16 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
|
||||
await screen.findByText('CI Bot');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /All accounts/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /All accounts/i }));
|
||||
|
||||
const activeOption = await screen.findByText(/Active ⎯/i);
|
||||
await user.click(activeOption);
|
||||
fireEvent.click(activeOption);
|
||||
|
||||
await screen.findByText('CI Bot');
|
||||
expect(screen.queryByText('Legacy Bot')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('search by name filters accounts in real-time', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<NuqsTestingAdapter>
|
||||
<ServiceAccountsSettings />
|
||||
@@ -153,10 +149,9 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
|
||||
await screen.findByText('CI Bot');
|
||||
|
||||
await user.type(
|
||||
screen.getByPlaceholderText(/Search by name or email/i),
|
||||
'legacy',
|
||||
);
|
||||
fireEvent.change(screen.getByPlaceholderText(/Search by name or email/i), {
|
||||
target: { value: 'legacy' },
|
||||
});
|
||||
|
||||
await screen.findByText('Legacy Bot');
|
||||
expect(screen.queryByText('CI Bot')).not.toBeInTheDocument();
|
||||
@@ -164,15 +159,13 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
});
|
||||
|
||||
it('clicking a row opens the drawer with account details visible', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<NuqsTestingAdapter hasMemory>
|
||||
<ServiceAccountsSettings />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', {
|
||||
name: /View service account CI Bot/i,
|
||||
}),
|
||||
@@ -184,7 +177,6 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
});
|
||||
|
||||
it('saving changes in the drawer refetches the list', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const listRefetchSpy = jest.fn();
|
||||
|
||||
server.use(
|
||||
@@ -206,15 +198,14 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
await screen.findByText('CI Bot');
|
||||
listRefetchSpy.mockClear();
|
||||
|
||||
await user.click(
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: /View service account CI Bot/i }),
|
||||
);
|
||||
|
||||
const nameInput = await screen.findByDisplayValue('CI Bot');
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, 'CI Bot Updated');
|
||||
fireEvent.change(nameInput, { target: { value: 'CI Bot Updated' } });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Save Changes/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Save Changes/i }));
|
||||
|
||||
await screen.findByDisplayValue('CI Bot Updated');
|
||||
await waitFor(() => {
|
||||
@@ -223,8 +214,6 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
});
|
||||
|
||||
it('"New Service Account" button opens the Create Service Account modal', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<NuqsTestingAdapter hasMemory>
|
||||
<ServiceAccountsSettings />
|
||||
@@ -233,9 +222,7 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
|
||||
await screen.findByText('CI Bot');
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /New Service Account/i }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /New Service Account/i }));
|
||||
|
||||
await screen.findByRole('dialog', { name: /New Service Account/i });
|
||||
expect(screen.getByPlaceholderText('Enter a name')).toBeInTheDocument();
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useGetUser, useSetRoleByUserID } from 'api/generated/services/users';
|
||||
import {
|
||||
getGetRolesByUserIDQueryKey,
|
||||
useGetRolesByUserID,
|
||||
useRemoveUserRoleByUserIDAndRoleID,
|
||||
useSetRoleByUserID,
|
||||
} from 'api/generated/services/users';
|
||||
import { retryOn429 } from 'utils/errorUtils';
|
||||
|
||||
const enum PromiseStatus {
|
||||
Fulfilled = 'fulfilled',
|
||||
Rejected = 'rejected',
|
||||
}
|
||||
|
||||
export interface MemberRoleUpdateFailure {
|
||||
roleName: string;
|
||||
error: unknown;
|
||||
@@ -10,7 +21,7 @@ export interface MemberRoleUpdateFailure {
|
||||
}
|
||||
|
||||
interface UseMemberRoleManagerResult {
|
||||
fetchedRoleIds: string[];
|
||||
currentRoles: AuthtypesRoleDTO[];
|
||||
isLoading: boolean;
|
||||
applyDiff: (
|
||||
localRoleIds: string[],
|
||||
@@ -22,66 +33,96 @@ export function useMemberRoleManager(
|
||||
userId: string,
|
||||
enabled: boolean,
|
||||
): UseMemberRoleManagerResult {
|
||||
const { data: fetchedUser, isLoading } = useGetUser(
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useGetRolesByUserID(
|
||||
{ id: userId },
|
||||
{ query: { enabled: !!userId && enabled } },
|
||||
);
|
||||
|
||||
const currentUserRoles = useMemo(
|
||||
() => fetchedUser?.data?.userRoles ?? [],
|
||||
[fetchedUser],
|
||||
);
|
||||
|
||||
const fetchedRoleIds = useMemo(
|
||||
() =>
|
||||
currentUserRoles
|
||||
.map((ur) => ur.role?.id ?? ur.roleId)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
[currentUserRoles],
|
||||
const currentRoles = useMemo<AuthtypesRoleDTO[]>(
|
||||
() => data?.data ?? [],
|
||||
[data?.data],
|
||||
);
|
||||
|
||||
const { mutateAsync: setRole } = useSetRoleByUserID({
|
||||
mutation: { retry: retryOn429 },
|
||||
});
|
||||
const { mutateAsync: removeRole } = useRemoveUserRoleByUserIDAndRoleID({
|
||||
mutation: { retry: retryOn429 },
|
||||
});
|
||||
|
||||
const invalidateRoles = useCallback(
|
||||
() =>
|
||||
queryClient.invalidateQueries(getGetRolesByUserIDQueryKey({ id: userId })),
|
||||
[userId, queryClient],
|
||||
);
|
||||
|
||||
const applyDiff = useCallback(
|
||||
async (
|
||||
localRoleIds: string[],
|
||||
availableRoles: AuthtypesRoleDTO[],
|
||||
): Promise<MemberRoleUpdateFailure[]> => {
|
||||
const currentRoleIdSet = new Set(fetchedRoleIds);
|
||||
const desiredRoleIdSet = new Set(localRoleIds.filter(Boolean));
|
||||
|
||||
const toAdd = availableRoles.filter(
|
||||
(r) => r.id && desiredRoleIdSet.has(r.id) && !currentRoleIdSet.has(r.id),
|
||||
const currentRoleIds = new Set(
|
||||
currentRoles.map((r) => r.id).filter(Boolean),
|
||||
);
|
||||
const desiredRoleIds = new Set(
|
||||
localRoleIds.filter((id) => id != null && id !== ''),
|
||||
);
|
||||
|
||||
/// TODO: re-enable deletes once BE for this is streamlined
|
||||
const allOps = [
|
||||
...toAdd.map((role) => ({
|
||||
roleName: role.name ?? 'unknown',
|
||||
const addedRoles = availableRoles.filter(
|
||||
(r) => r.id && desiredRoleIds.has(r.id) && !currentRoleIds.has(r.id),
|
||||
);
|
||||
const removedRoles = currentRoles.filter(
|
||||
(r) => r.id && !desiredRoleIds.has(r.id),
|
||||
);
|
||||
|
||||
const allOperations = [
|
||||
...addedRoles.map((role) => ({
|
||||
role,
|
||||
run: (): ReturnType<typeof setRole> =>
|
||||
setRole({
|
||||
pathParams: { id: userId },
|
||||
data: { name: role.name ?? '' },
|
||||
}),
|
||||
})),
|
||||
...removedRoles.map((role) => ({
|
||||
role,
|
||||
run: (): ReturnType<typeof removeRole> =>
|
||||
removeRole({ pathParams: { id: userId, roleId: role.id ?? '' } }),
|
||||
})),
|
||||
];
|
||||
|
||||
const results = await Promise.allSettled(allOps.map((op) => op.run()));
|
||||
const results = await Promise.allSettled(
|
||||
allOperations.map((op) => op.run()),
|
||||
);
|
||||
|
||||
const successCount = results.filter(
|
||||
(r) => r.status === PromiseStatus.Fulfilled,
|
||||
).length;
|
||||
if (successCount > 0) {
|
||||
await invalidateRoles();
|
||||
}
|
||||
|
||||
const failures: MemberRoleUpdateFailure[] = [];
|
||||
results.forEach((result, i) => {
|
||||
if (result.status === 'rejected') {
|
||||
const { roleName, run } = allOps[i];
|
||||
failures.push({ roleName, error: result.reason, onRetry: run });
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === PromiseStatus.Rejected) {
|
||||
const { role, run } = allOperations[index];
|
||||
failures.push({
|
||||
roleName: role.name ?? 'unknown',
|
||||
error: result.reason,
|
||||
onRetry: async (): Promise<void> => {
|
||||
await run();
|
||||
await invalidateRoles();
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return failures;
|
||||
},
|
||||
[userId, fetchedRoleIds, setRole],
|
||||
[userId, currentRoles, setRole, removeRole, invalidateRoles],
|
||||
);
|
||||
|
||||
return { fetchedRoleIds, isLoading, applyDiff };
|
||||
return { currentRoles, isLoading, applyDiff };
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function AlertState({
|
||||
let label;
|
||||
const isDarkMode = useIsDarkMode();
|
||||
switch (state) {
|
||||
case 'no-data':
|
||||
case 'nodata':
|
||||
icon = (
|
||||
<CircleOff
|
||||
size={18}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
createRule,
|
||||
deleteRuleByID,
|
||||
invalidateGetRuleByID,
|
||||
updateRuleByID,
|
||||
useGetRuleByID,
|
||||
useListRules,
|
||||
@@ -408,6 +409,7 @@ export const useAlertRuleStatusToggle = ({
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
setAlertRuleState(data.data.state);
|
||||
invalidateGetRuleByID(queryClient, { id: ruleId });
|
||||
queryClient.refetchQueries([REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId]);
|
||||
notifications.success({
|
||||
message: `Alert has been ${
|
||||
@@ -416,6 +418,7 @@ export const useAlertRuleStatusToggle = ({
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
invalidateGetRuleByID(queryClient, { id: ruleId });
|
||||
queryClient.refetchQueries([REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId]);
|
||||
showErrorModal(
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
|
||||
|
||||
@@ -109,7 +109,8 @@ export type AlertRuleTimelineTableResponsePayload = {
|
||||
labels: AlertLabelsProps['labels'];
|
||||
};
|
||||
};
|
||||
type AlertState = 'firing' | 'normal' | 'no-data' | 'muted';
|
||||
|
||||
type AlertState = 'firing' | 'normal' | 'nodata' | 'muted';
|
||||
|
||||
export interface AlertRuleTimelineGraphResponse {
|
||||
start: number;
|
||||
|
||||
@@ -874,45 +874,22 @@ func (module *setter) AddUserRole(ctx context.Context, orgID, userID valuer.UUID
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "role name not found: %s", roleName)
|
||||
}
|
||||
|
||||
// check if user already has this role
|
||||
existingUserRoles, err := module.getter.GetRolesByUserID(ctx, existingUser.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existingRoles := make([]string, len(existingUserRoles))
|
||||
for idx, role := range existingUserRoles {
|
||||
existingRoles[idx] = role.Role.Name
|
||||
}
|
||||
|
||||
// grant via authz (idempotent)
|
||||
if err := module.authz.ModifyGrant(
|
||||
// grant via authz (additive, idempotent — OpenFGA uses OnDuplicate: "ignore")
|
||||
if err := module.authz.Grant(
|
||||
ctx,
|
||||
orgID,
|
||||
existingRoles,
|
||||
[]string{roleName},
|
||||
authtypes.MustNewSubject(coretypes.NewResourceUser(), existingUser.ID.StringValue(), existingUser.OrgID, nil),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// create user_role entry
|
||||
// create user_role entry (swallow AlreadyExists for idempotency — DB has unique constraint on user_id+role_id)
|
||||
userRoles := authtypes.NewUserRoles(userID, foundRoles)
|
||||
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
err = module.userRoleStore.DeleteUserRoles(ctx, existingUser.ID)
|
||||
if err != nil {
|
||||
if err := module.userRoleStore.CreateUserRoles(ctx, userRoles); err != nil {
|
||||
if !errors.Ast(err, errors.TypeAlreadyExists) {
|
||||
return err
|
||||
}
|
||||
|
||||
err := module.userRoleStore.CreateUserRoles(ctx, userRoles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return module.tokenizer.DeleteIdentity(ctx, userID)
|
||||
|
||||
@@ -134,7 +134,7 @@ type RuleStateHistory struct {
|
||||
// One of ["normal", "firing"]
|
||||
OverallState AlertState `json:"overallState" ch:"overall_state"`
|
||||
OverallStateChanged bool `json:"overallStateChanged" ch:"overall_state_changed"`
|
||||
// One of ["normal", "firing", "no_data", "muted"]
|
||||
// One of ["normal", "firing", "nodata", "muted"]
|
||||
State AlertState `json:"state" ch:"state"`
|
||||
StateChanged bool `json:"stateChanged" ch:"state_changed"`
|
||||
UnixMilli int64 `json:"unixMilli" ch:"unix_milli"`
|
||||
|
||||
5
tests/fixtures/querier.py
vendored
5
tests/fixtures/querier.py
vendored
@@ -69,7 +69,7 @@ class BuilderQuery:
|
||||
class TraceOperatorQuery:
|
||||
name: str
|
||||
expression: str
|
||||
return_spans_from: str
|
||||
return_spans_from: str | None = None
|
||||
limit: int | None = None
|
||||
order: list[OrderBy] | None = None
|
||||
|
||||
@@ -77,8 +77,9 @@ class TraceOperatorQuery:
|
||||
spec: dict[str, Any] = {
|
||||
"name": self.name,
|
||||
"expression": self.expression,
|
||||
"returnSpansFrom": self.return_spans_from,
|
||||
}
|
||||
if self.return_spans_from is not None:
|
||||
spec["returnSpansFrom"] = self.return_spans_from
|
||||
if self.limit is not None:
|
||||
spec["limit"] = self.limit
|
||||
if self.order:
|
||||
|
||||
@@ -129,11 +129,11 @@ def test_get_user_roles(
|
||||
assert "type" in role
|
||||
|
||||
|
||||
def test_assign_role_replaces_previous(
|
||||
def test_assign_role_is_additive(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
"""Verify POST /api/v2/users/{id}/roles replaces existing role."""
|
||||
"""Verify POST /api/v2/users/{id}/roles ADDS a role alongside existing ones and is idempotent."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
@@ -144,6 +144,8 @@ def test_assign_role_replaces_previous(
|
||||
me = response.json()["data"]
|
||||
user_id = me["id"]
|
||||
|
||||
# User currently has signoz-admin from test_change_role.
|
||||
# Assign signoz-editor — should be additive, admin stays.
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles"),
|
||||
json={"name": "signoz-editor"},
|
||||
@@ -160,8 +162,29 @@ def test_assign_role_replaces_previous(
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
roles = response.json()["data"]
|
||||
names = {r["name"] for r in roles}
|
||||
assert len(names) == 2
|
||||
assert "signoz-editor" in names
|
||||
assert "signoz-admin" not in names
|
||||
assert "signoz-admin" in names
|
||||
|
||||
# Idempotency: assigning the same role again succeeds without duplicates
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles"),
|
||||
json={"name": "signoz-editor"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
roles = response.json()["data"]
|
||||
editor_count = sum(1 for r in roles if r["name"] == "signoz-editor")
|
||||
assert editor_count == 1
|
||||
assert len(roles) == 2
|
||||
|
||||
|
||||
def test_get_users_by_role(
|
||||
@@ -202,7 +225,7 @@ def test_remove_role(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
"""Verify DELETE /api/v2/users/{id}/roles/{roleId} removes the role."""
|
||||
"""Verify DELETE /api/v2/users/{id}/roles/{roleId} removes only the specified role."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
@@ -237,7 +260,10 @@ def test_remove_role(
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
roles_after = response.json()["data"]
|
||||
assert len(roles_after) == 0
|
||||
names_after = {r["name"] for r in roles_after}
|
||||
assert len(roles_after) == 1
|
||||
assert "signoz-editor" not in names_after
|
||||
assert "signoz-admin" in names_after
|
||||
|
||||
|
||||
def test_user_with_roles_reflects_change(
|
||||
@@ -262,7 +288,8 @@ def test_user_with_roles_reflects_change(
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
data = response.json()["data"]
|
||||
role_names = {ur["role"]["name"] for ur in data["userRoles"]}
|
||||
assert len(role_names) == 0
|
||||
assert len(role_names) == 1
|
||||
assert "signoz-admin" in role_names
|
||||
|
||||
|
||||
def test_admin_cannot_assign_role_to_self(
|
||||
|
||||
@@ -625,7 +625,6 @@ def test_export_traces_with_composite_query_trace_operator(
|
||||
query_c = TraceOperatorQuery(
|
||||
name="C",
|
||||
expression="A => B",
|
||||
return_spans_from="A",
|
||||
limit=1000,
|
||||
order=[OrderBy(TelemetryFieldKey("timestamp", "string", "span"), "desc")],
|
||||
)
|
||||
@@ -652,17 +651,15 @@ def test_export_traces_with_composite_query_trace_operator(
|
||||
|
||||
# Parse JSONL content
|
||||
jsonl_lines = response.text.strip().split("\n")
|
||||
assert len(jsonl_lines) == 1, f"Expected at least 1 line, got {len(jsonl_lines)}"
|
||||
assert len(jsonl_lines) >= 1, f"Expected at least 1 line, got {len(jsonl_lines)}"
|
||||
|
||||
# Verify all returned spans belong to the matched trace
|
||||
# Verify all returned spans belong to the matched trace.
|
||||
# The direct-descendant JOIN emits one row per matching child, so the parent
|
||||
# span may appear more than once (once per child that satisfies the condition).
|
||||
json_objects = [json.loads(line) for line in jsonl_lines]
|
||||
trace_ids = [obj.get("trace_id") for obj in json_objects]
|
||||
assert all(tid == parent_trace_id for tid in trace_ids)
|
||||
|
||||
# Verify the parent span (returnSpansFrom = "A") is present
|
||||
span_names = [obj.get("name") for obj in json_objects]
|
||||
assert "parent-operation" in span_names
|
||||
|
||||
|
||||
def test_export_traces_with_select_fields(
|
||||
signoz: types.SigNoz,
|
||||
|
||||
Reference in New Issue
Block a user