This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new 931b5a036 fix(studio): handle rejected, cancelled and unauthorized
writes (#5839)
931b5a036 is described below
commit 931b5a036263b31c6d19700fa1f85a4b76381fc7
Author: btlqql <[email protected]>
AuthorDate: Mon Oct 12 16:12:16 2026 +0800
fix(studio): handle rejected, cancelled and unauthorized writes (#5839)
Three fixes from the same author on the studio user-management and
general-settings write
surfaces — a rejected validation, a cancelled dialog and a non-admin caller
— folded into a
single commit.
- #5839 `web/src/pages/studio/UserManagement.tsx:249` and `:298` await
`createForm.validateFields()` / `passwordForm.validateFields()` outside
the `try`, while both
modals invoke the handlers fire-and-forget (`:754 onOk={() => void
createUser()}`,
`:788 onOk={() => void updatePassword()}`), so clicking OK with a
required field empty escapes
as an unhandled promise rejection that antd has already reported
visually; moves both awaits
inside the `try`, narrows the catch and returns silently when the
rejection carries
`errorFields`, keeping `message.error(t('userMgmt.createFailed'))` /
`t('userMgmt.changePasswordFailed')` for every other failure — the guard
trunk already applies
in `instance/index.tsx:370`, `ops/alerts.tsx:830`,
`settings/CloudCredentialTab.tsx:221` and
three more call sites.
- #5840 `web/src/pages/studio/UserManagement.tsx:755` makes the create-user
modal's `onCancel`
do nothing but close it, the `Form` at `:757` keeps antd's default
`preserve`, and
`openCreateUserModal` (`:339`) does not reset either, so reopening after
a cancel refills the
username and the plaintext initial password — only the success path
resets (`:254`), while the
sibling password modal has reset on cancel all along (`:789-792`); resets
`createForm` in
`onCancel`, adds `destroyOnHidden` to the modal and `preserve={false}` to
the form, matching
`settings/CloudCredentialTab.tsx:414` and
`settings/DataSourceTab.tsx:479`.
- #5849 `web/src/pages/settings/GeneralSettingsTab.tsx` never reads
`authStore` and the settings
route has no admin guard, so a signed-in reader can flip theme
(`:247-251`) or compact mode
(`:264-267`) and fire `saveGeneralSettings` (`:127`), which
`AuthInterceptor.requiresAdmin`
(`server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.java:138-149`)
always answers with 403 — the catch at `:130-131` then raises
`settings.saveFailed` for a
preference that already took effect locally, and the four other write
entries (`:152`, `:171`,
`:185`, `:204`) plus six buttons are just as clickable and just as
doomed; derives
`canWriteSettings = !authUserId || authAdmin === true` from `authStore`,
gates all five write
paths and disables the six buttons, the same derivation trunk uses in
`studio/Ops.tsx:72`,
`ops/systemAlerts.tsx:139` and `ai/index.tsx:79` — the server-side
boundary is untouched, this
only stops the UI offering writes it knows will be refused.
Five new cases in three new test files (2 for #5839, 1 for #5840, 2 for
#5849), all
mutation-sensitive: dropping the `errorFields` branch makes
`expect(unhandled.reasons).toEqual([])`
fail for both modals, dropping the reset / `preserve` / `destroyOnHidden`
trio leaves `alice` and
`super-secret-1` in the reopened form, and dropping the `persistPreference`
gate makes a reader's
theme click reach `saveGeneralSettings` and raise the failure toast while
dropping the button
`disabled` fails `toBeDisabled()`. The expected toast text is matched
verbatim against
`web/src/i18n/translations.ts:1238-1241`, so none of these assertions can
pass vacuously, and the
reader cases run against a file-level `authStore` mock that the existing
`GeneralSettingsTab.test.tsx` cannot provide.
Folded: #5840, #5849 were closed as folded into this commit (same author).
---
web/src/pages/settings/GeneralSettingsTab.tsx | 40 ++++-
.../__tests__/GeneralSettingsTabReader.test.tsx | 128 ++++++++++++++
web/src/pages/studio/UserManagement.tsx | 22 ++-
.../UserManagementCreateModalResidue.test.tsx | 138 +++++++++++++++
.../UserManagementModalValidation.test.tsx | 189 +++++++++++++++++++++
5 files changed, 504 insertions(+), 13 deletions(-)
diff --git a/web/src/pages/settings/GeneralSettingsTab.tsx
b/web/src/pages/settings/GeneralSettingsTab.tsx
index dc9f16113..7680bcd4e 100644
--- a/web/src/pages/settings/GeneralSettingsTab.tsx
+++ b/web/src/pages/settings/GeneralSettingsTab.tsx
@@ -35,6 +35,7 @@ import type { GeneralSettings, GeneralSettingsUpdate } from
'../../api/settings'
import { useTheme } from '../../theme/useTheme';
import type { ThemeMode } from '../../theme/themePreference';
import { useLang } from '../../i18n/LangContext';
+import useAuthStore from '../../stores/authStore';
const { Text } = Typography;
@@ -70,6 +71,12 @@ export const GeneralSettingsTab = () => {
const [testingChannel, setTestingChannel] = useState<string>();
const securityInFlightRef = useRef(false);
const notifyInFlightRef = useRef(false);
+ const authUserId = useAuthStore((state) => state.userId);
+ const authAdmin = useAuthStore((state) => state.admin);
+ // Mirrors the server policy: POST /api/settings/general/save is admin-only,
and the
+ // server also hands redacted webhook values to readers. Without a signed-in
studio
+ // user (auth disabled) the caller counts as system, so writes stay allowed.
+ const canWriteSettings = !authUserId || authAdmin === true;
const translationRef = useRef(t);
const [securityForm] = Form.useForm();
const [notifyForm] = Form.useForm();
@@ -121,6 +128,9 @@ export const GeneralSettingsTab = () => {
if (!settings) return;
const next = { ...settings, ...patch };
setSettings(next);
+ // Readers cannot write the shared record; their preference already applied
+ // locally via useTheme, so skip the round-trip that could only end in 403.
+ if (!canWriteSettings) return;
setSavingPreference(true);
try {
const base = await loadFreshSettings();
@@ -150,7 +160,7 @@ export const GeneralSettingsTab = () => {
};
const clearDingtalkSigningSecret = async () => {
- if (!settings) return;
+ if (!settings || !canWriteSettings) return;
setSavingNotification(true);
try {
if (
@@ -169,7 +179,7 @@ export const GeneralSettingsTab = () => {
};
const handleSecurityFinish = async (values: { sessionTimeout: number }) => {
- if (securityInFlightRef.current) return;
+ if (!canWriteSettings || securityInFlightRef.current) return;
securityInFlightRef.current = true;
setSavingSecurity(true);
try {
@@ -188,7 +198,7 @@ export const GeneralSettingsTab = () => {
emailRecipients?: string;
smsWebhook?: string;
}) => {
- if (notifyInFlightRef.current) return;
+ if (!canWriteSettings || notifyInFlightRef.current) return;
notifyInFlightRef.current = true;
setSavingNotification(true);
try {
@@ -202,6 +212,7 @@ export const GeneralSettingsTab = () => {
};
const sendTest = async (channel: 'dingtalk' | 'email' | 'sms') => {
+ if (!canWriteSettings) return;
setTestingChannel(channel);
try {
const values = await notifyForm.validateFields();
@@ -303,7 +314,12 @@ export const GeneralSettingsTab = () => {
</Form.Item>
<Form.Item style={{ marginBottom: 16 }}>
- <Button type="primary" htmlType="submit" loading={savingSecurity}
disabled={loading}>
+ <Button
+ type="primary"
+ htmlType="submit"
+ loading={savingSecurity}
+ disabled={loading || !canWriteSettings}
+ >
{t('settings.saveSettings')}
</Button>
</Form.Item>
@@ -344,6 +360,7 @@ export const GeneralSettingsTab = () => {
danger
onClick={() => void clearDingtalkSigningSecret()}
loading={savingNotification}
+ disabled={!canWriteSettings}
>
{t('settings.clearDingtalkSigningSecret')}
</Button>
@@ -372,13 +389,22 @@ export const GeneralSettingsTab = () => {
<Button
onClick={() => void sendTest('dingtalk')}
loading={testingChannel === 'dingtalk'}
+ disabled={!canWriteSettings}
>
{t('settings.testDingtalk')}
</Button>
- <Button onClick={() => void sendTest('email')}
loading={testingChannel === 'email'}>
+ <Button
+ onClick={() => void sendTest('email')}
+ loading={testingChannel === 'email'}
+ disabled={!canWriteSettings}
+ >
{t('settings.testEmail')}
</Button>
- <Button onClick={() => void sendTest('sms')}
loading={testingChannel === 'sms'}>
+ <Button
+ onClick={() => void sendTest('sms')}
+ loading={testingChannel === 'sms'}
+ disabled={!canWriteSettings}
+ >
{t('settings.testSmsWebhook')}
</Button>
</Space>
@@ -386,7 +412,7 @@ export const GeneralSettingsTab = () => {
type="primary"
htmlType="submit"
loading={savingNotification}
- disabled={loading}
+ disabled={loading || !canWriteSettings}
>
{t('settings.saveSettings')}
</Button>
diff --git a/web/src/pages/settings/__tests__/GeneralSettingsTabReader.test.tsx
b/web/src/pages/settings/__tests__/GeneralSettingsTabReader.test.tsx
new file mode 100644
index 000000000..646c3e42d
--- /dev/null
+++ b/web/src/pages/settings/__tests__/GeneralSettingsTabReader.test.tsx
@@ -0,0 +1,128 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); 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.
+ */
+
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { App } from 'antd';
+import { getGeneralSettings, saveGeneralSettings } from
'../../../api/settings';
+import { ThemeProvider } from '../../../theme/ThemeProvider';
+import { LangProvider } from '../../../i18n/LangContext';
+import { LANGUAGE_STORAGE_KEY } from '../../../i18n/languagePreference';
+import { GeneralSettingsTab } from '../GeneralSettingsTab';
+
+type MockAuthState = { admin: boolean | null; userId: number | null; logout:
() => void };
+vi.mock('../../../stores/authStore', () => ({
+ default: (selector: (state: MockAuthState) => unknown) =>
+ selector({ admin: false, userId: 42, logout: vi.fn() }),
+}));
+
+vi.mock('../../../api/settings', () => ({
+ createDataSource: vi.fn(),
+ deleteDataSource: vi.fn(),
+ getGeneralSettings: vi.fn(),
+ listDataSources: vi.fn(),
+ saveGeneralSettings: vi.fn(),
+ testDataSource: vi.fn(),
+ updateDataSource: vi.fn(),
+}));
+
+beforeAll(() => {
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+});
+
+const renderTab = () =>
+ render(
+ <App>
+ <LangProvider>
+ <ThemeProvider>
+ <GeneralSettingsTab />
+ </ThemeProvider>
+ </LangProvider>
+ </App>,
+ );
+
+describe('GeneralSettingsTab reader access', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ localStorage.clear();
+ localStorage.setItem(LANGUAGE_STORAGE_KEY, 'en');
+ // The server hands redacted webhook values to readers and rejects their
+ // writes with 403 (POST /api/settings/general/save is admin-only).
+ vi.mocked(getGeneralSettings).mockResolvedValue({
+ theme: 'system',
+ compact: false,
+ desktopNotify: false,
+ notifySound: false,
+ sessionTimeout: 30,
+ requireLogin: true,
+ llmProvider: 'tongyi',
+ apiKeyConfigured: false,
+ model: 'qwen',
+ baseUrl: 'https://example.com/v1',
+ dingtalkWebhook: '******',
+ dingtalkSigningSecretConfigured: true,
+ emailRecipients: '[email protected]',
+ smsWebhook: '******',
+ smsWebhookConfigured: true,
+ });
+ vi.mocked(saveGeneralSettings).mockRejectedValue({
+ response: { status: 403, data: { message: 'Forbidden' } },
+ });
+ });
+
+ it('applies a reader theme switch locally without a doomed settings write',
async () => {
+ renderTab();
+
+ fireEvent.click(await screen.findByText('Dark'));
+ // Give the doomed save round-trip (fresh GET, then POST) time to fire.
+ await new Promise((resolve) => {
+ setTimeout(resolve, 200);
+ });
+
+ expect(saveGeneralSettings).not.toHaveBeenCalled();
+ expect(
+ screen.queryByText('Failed to save settings. Please try again later.'),
+ ).not.toBeInTheDocument();
+ });
+
+ it('disables the admin-only save and test buttons for readers', async () => {
+ renderTab();
+
+ await screen.findByText('Test DingTalk');
+
+ const saveButtons = screen.getAllByRole('button', { name: 'Save settings'
});
+ expect(saveButtons).toHaveLength(2);
+ for (const button of saveButtons) {
+ expect(button).toBeDisabled();
+ }
+ expect(screen.getByRole('button', { name: 'Test DingTalk'
})).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Test email' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Test SMS/Webhook'
})).toBeDisabled();
+ });
+});
diff --git a/web/src/pages/studio/UserManagement.tsx
b/web/src/pages/studio/UserManagement.tsx
index 1dae92cac..cd11dc837 100644
--- a/web/src/pages/studio/UserManagement.tsx
+++ b/web/src/pages/studio/UserManagement.tsx
@@ -251,15 +251,19 @@ const UserManagementPage = () => {
};
const createUser = async () => {
- const values = await createForm.validateFields();
try {
+ const values = await createForm.validateFields();
await createStudioUser(values);
message.success(t('userMgmt.userCreated'));
setCreateOpen(false);
createForm.resetFields();
if (page === 1) await loadUsers();
else setPage(1);
- } catch {
+ } catch (error) {
+ // Validation failures are surfaced by antd as field errors; only report
+ // request failures here. Either way the rejection must not escape this
+ // handler, since the modal wiring calls it fire-and-forget.
+ if (error && typeof error === 'object' && 'errorFields' in error) return;
message.error(t('userMgmt.createFailed'));
}
};
@@ -300,8 +304,8 @@ const UserManagementPage = () => {
const updatePassword = async () => {
if (!passwordTarget) return;
- const values = await passwordForm.validateFields();
try {
+ const values = await passwordForm.validateFields();
if (passwordTarget.id === userId) {
await changePassword(values.currentPassword ?? '', values.newPassword);
clearAuth();
@@ -313,7 +317,9 @@ const UserManagementPage = () => {
}
setPasswordTarget(null);
passwordForm.resetFields();
- } catch {
+ } catch (error) {
+ // See createUser: validation rejections must stay handled here.
+ if (error && typeof error === 'object' && 'errorFields' in error) return;
message.error(t('userMgmt.changePasswordFailed'));
}
};
@@ -759,9 +765,13 @@ const UserManagementPage = () => {
title={t('userMgmt.createTitle')}
open={createOpen}
onOk={() => void createUser()}
- onCancel={() => setCreateOpen(false)}
+ onCancel={() => {
+ setCreateOpen(false);
+ createForm.resetFields();
+ }}
+ destroyOnHidden
>
- <Form form={createForm} layout="vertical" initialValues={{ admin:
false }}>
+ <Form form={createForm} layout="vertical" initialValues={{ admin:
false }} preserve={false}>
<Form.Item
name="username"
label={t('userMgmt.username')}
diff --git
a/web/src/pages/studio/__tests__/UserManagementCreateModalResidue.test.tsx
b/web/src/pages/studio/__tests__/UserManagementCreateModalResidue.test.tsx
new file mode 100644
index 000000000..b65a1996d
--- /dev/null
+++ b/web/src/pages/studio/__tests__/UserManagementCreateModalResidue.test.tsx
@@ -0,0 +1,138 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); 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.
+ */
+
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { App } from 'antd';
+import { MemoryRouter } from 'react-router-dom';
+import {
+ getStudioUserSessionOverview,
+ listStudioUsers,
+ type StudioUser,
+} from '../../../api/studioUsers';
+import { LangProvider } from '../../../i18n/LangContext';
+import { LANGUAGE_STORAGE_KEY } from '../../../i18n/languagePreference';
+import UserManagementPage from '../UserManagement';
+
+type MockAuthState = { admin: boolean; userId: number; logout: () => void };
+vi.mock('../../../api/studioUsers', () => ({
+ createStudioUser: vi.fn(),
+ getStudioUserSessionOverview: vi.fn(),
+ listAllStudioUsers: vi.fn(),
+ listStudioUserSessions: vi.fn(),
+ listStudioUsers: vi.fn(),
+ resetStudioUserPassword: vi.fn(),
+ revokeStudioUserSessions: vi.fn(),
+ setStudioUserEnabled: vi.fn(),
+}));
+
+vi.mock('../../../stores/authStore', () => ({
+ default: (selector: (state: MockAuthState) => unknown) =>
+ selector({ admin: true, userId: 1, logout: vi.fn() }),
+}));
+
+vi.mock('../../../utils/download', async () => {
+ const downloadModule =
+ await vi.importActual<typeof
import('../../../utils/download')>('../../../utils/download');
+ return {
+ ...downloadModule,
+ downloadCsv: vi.fn(),
+ };
+});
+
+const studioUserPage = {
+ items: [
+ {
+ id: 7,
+ username: 'operator',
+ admin: false,
+ enabled: true,
+ activeSessionCount: 2,
+ lastSessionSeenAt: '2026-08-22T09:30:00',
+ nearestSessionExpiresAt: '2026-08-22T10:00:00',
+ passwordChangedAt: '2026-08-22T08:00:00',
+ gmtCreate: '2026-08-22T08:00:00',
+ gmtModified: '2026-08-22T08:00:00',
+ } satisfies StudioUser,
+ ],
+ total: 1,
+ page: 1,
+ size: 20,
+};
+
+beforeAll(() => {
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+});
+
+const renderPage = () =>
+ render(
+ <MemoryRouter>
+ <App>
+ <LangProvider>
+ <UserManagementPage />
+ </LangProvider>
+ </App>
+ </MemoryRouter>,
+ );
+
+describe('UserManagement create user modal residue', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ localStorage.setItem(LANGUAGE_STORAGE_KEY, 'en');
+ vi.mocked(listStudioUsers).mockResolvedValue(studioUserPage);
+ vi.mocked(getStudioUserSessionOverview).mockResolvedValue({
+ activeSessionCount: 2,
+ activeUserCount: 1,
+ expiringSoonSessionCount: 0,
+ expiringSoonWindowMinutes: 30,
+ staleSessionCount: 0,
+ staleSessionThresholdMinutes: 120,
+ });
+ });
+
+ it('clears the typed username and password after cancelling the create
dialog', async () => {
+ try {
+ const user = userEvent.setup();
+ renderPage();
+
+ await user.click(await screen.findByRole('button', { name: 'Create User'
}));
+ await user.type(screen.getByLabelText('Username'), 'alice');
+ await user.type(screen.getByLabelText('Initial Password'),
'super-secret-1');
+ await user.click(screen.getByRole('button', { name: 'Cancel' }));
+
+ await user.click(screen.getByRole('button', { name: 'Create User' }));
+
+ expect(screen.getByLabelText('Username')).toHaveValue('');
+ expect(screen.getByLabelText('Initial Password')).toHaveValue('');
+ } finally {
+ localStorage.removeItem(LANGUAGE_STORAGE_KEY);
+ }
+ });
+});
diff --git
a/web/src/pages/studio/__tests__/UserManagementModalValidation.test.tsx
b/web/src/pages/studio/__tests__/UserManagementModalValidation.test.tsx
new file mode 100644
index 000000000..f1efc59eb
--- /dev/null
+++ b/web/src/pages/studio/__tests__/UserManagementModalValidation.test.tsx
@@ -0,0 +1,189 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); 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.
+ */
+
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { App } from 'antd';
+import { MemoryRouter } from 'react-router-dom';
+import {
+ createStudioUser,
+ getStudioUserSessionOverview,
+ listStudioUsers,
+ resetStudioUserPassword,
+ type StudioUser,
+} from '../../../api/studioUsers';
+import { LangProvider } from '../../../i18n/LangContext';
+import { LANGUAGE_STORAGE_KEY } from '../../../i18n/languagePreference';
+import UserManagementPage from '../UserManagement';
+
+type MockAuthState = { admin: boolean; userId: number; logout: () => void };
+vi.mock('../../../api/studioUsers', () => ({
+ createStudioUser: vi.fn(),
+ getStudioUserSessionOverview: vi.fn(),
+ listAllStudioUsers: vi.fn(),
+ listStudioUserSessions: vi.fn(),
+ listStudioUsers: vi.fn(),
+ resetStudioUserPassword: vi.fn(),
+ revokeStudioUserSessions: vi.fn(),
+ setStudioUserEnabled: vi.fn(),
+}));
+
+vi.mock('../../../stores/authStore', () => ({
+ default: (selector: (state: MockAuthState) => unknown) =>
+ selector({ admin: true, userId: 1, logout: vi.fn() }),
+}));
+
+vi.mock('../../../utils/download', async () => {
+ const downloadModule =
+ await vi.importActual<typeof
import('../../../utils/download')>('../../../utils/download');
+ return {
+ ...downloadModule,
+ downloadCsv: vi.fn(),
+ };
+});
+
+const studioUserPage = {
+ items: [
+ {
+ id: 7,
+ username: 'operator',
+ admin: false,
+ enabled: true,
+ activeSessionCount: 2,
+ lastSessionSeenAt: '2026-08-22T09:30:00',
+ nearestSessionExpiresAt: '2026-08-22T10:00:00',
+ passwordChangedAt: '2026-08-22T08:00:00',
+ gmtCreate: '2026-08-22T08:00:00',
+ gmtModified: '2026-08-22T08:00:00',
+ } satisfies StudioUser,
+ ],
+ total: 1,
+ page: 1,
+ size: 20,
+};
+
+beforeAll(() => {
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+});
+
+const renderPage = () =>
+ render(
+ <MemoryRouter>
+ <App>
+ <LangProvider>
+ <UserManagementPage />
+ </LangProvider>
+ </App>
+ </MemoryRouter>,
+ );
+
+const collectUnhandledRejections = () => {
+ const reasons: unknown[] = [];
+ const handler = (reason: unknown) => {
+ reasons.push(reason);
+ };
+ const nodeProcess = (globalThis as typeof globalThis & {
+ process: {
+ on: (event: 'unhandledRejection', listener: (reason: unknown) => void)
=> void;
+ off: (event: 'unhandledRejection', listener: (reason: unknown) => void)
=> void;
+ };
+ }).process;
+ nodeProcess.on('unhandledRejection', handler);
+ return {
+ reasons,
+ restore: () => {
+ nodeProcess.off('unhandledRejection', handler);
+ },
+ };
+};
+
+const flushMicrotasks = async () => {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 50);
+ });
+};
+
+describe('UserManagement modal validation failures', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ localStorage.setItem(LANGUAGE_STORAGE_KEY, 'en');
+ vi.mocked(listStudioUsers).mockResolvedValue(studioUserPage);
+ vi.mocked(getStudioUserSessionOverview).mockResolvedValue({
+ activeSessionCount: 2,
+ activeUserCount: 1,
+ expiringSoonSessionCount: 0,
+ expiringSoonWindowMinutes: 30,
+ staleSessionCount: 0,
+ staleSessionThresholdMinutes: 120,
+ });
+ });
+
+ it('does not leak an unhandled rejection when user creation validation
fails', async () => {
+ const unhandled = collectUnhandledRejections();
+ try {
+ const user = userEvent.setup();
+ renderPage();
+ await user.click(await screen.findByRole('button', { name: 'Create User'
}));
+ await user.click(await screen.findByRole('button', { name: 'OK' }));
+
+ await waitFor(() => {
+
expect(document.querySelectorAll('.ant-form-item-explain-error').length).toBeGreaterThan(0);
+ });
+ expect(createStudioUser).not.toHaveBeenCalled();
+
+ await flushMicrotasks();
+ expect(unhandled.reasons).toEqual([]);
+ } finally {
+ unhandled.restore();
+ localStorage.removeItem(LANGUAGE_STORAGE_KEY);
+ }
+ });
+
+ it('does not leak an unhandled rejection when password reset validation
fails', async () => {
+ const unhandled = collectUnhandledRejections();
+ try {
+ const user = userEvent.setup();
+ renderPage();
+ await user.click(await screen.findByRole('button', { name: 'Password'
}));
+ await user.click(await screen.findByRole('button', { name: 'OK' }));
+
+ await waitFor(() => {
+
expect(document.querySelectorAll('.ant-form-item-explain-error').length).toBeGreaterThan(0);
+ });
+ expect(resetStudioUserPassword).not.toHaveBeenCalled();
+
+ await flushMicrotasks();
+ expect(unhandled.reasons).toEqual([]);
+ } finally {
+ unhandled.restore();
+ localStorage.removeItem(LANGUAGE_STORAGE_KEY);
+ }
+ });
+});