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 f4c3f04c1 fix(web): reject writes on absent resources and double 
submits (#6044)
f4c3f04c1 is described below

commit f4c3f04c1f53ada7829824867850fce21b4efaa2
Author: messere <[email protected]>
AuthorDate: Mon Oct 12 15:33:05 2026 +0800

    fix(web): reject writes on absent resources and double submits (#6044)
    
    Three fixes from the same author for operations that report success when 
they should not —
    two service-layer lookups that swallow a missing record and one destructive 
dialog with no
    submit lock — folded into a single commit.
    
    - #6042 `web/src/services/alertRuleAssetService.ts:35` resolves an empty 
YAML Blob from the mock
      branch of `exportAlertRuleAsset` when the name is not in 
`mockAlertRuleAssets`, while
      `getAlertRuleAsset` in the same file already throws (`:24-26`) and the 
backend raises
      `BusinessException(404, "Alert rule asset not found: " + name)`
      
(`server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleAssetService.java:100`),
 so
      the demo data source silently exports nothing where the real one reports 
404; throws the same
      message and pulls the duplicated `find` out of get and export into one
      `getMockAlertRuleAsset()` helper.
    - #6043 `web/src/services/aclService.ts:168` and `:213` splice only when 
the index is found and
      otherwise return without a word, so `deleteAclRule` / `deleteAclUser` 
report success for a
      record that is not there while `updateAclRule` (`:154`) and 
`updateAclUser` (`:199`) in the same
      file already throw `ACL … not found` — and 
`web/src/pages/instance/acl.tsx:349-356` holds no
      in-flight lock and refreshes the list asynchronously, so confirming the 
same row again before
      the refresh lands shows `acl.ruleDeleted` for a no-op; throws on a 
missing index with the text
      the backend 404 uses, which routes that second confirm into the page's 
existing
      `common.operationFailed` branch.
    - #6044 `web/src/pages/ops/audit.tsx:291-301` gives `handleCleanup` no 
in-flight protection and
      the cleanup Modal (`:561-568`) no `confirmLoading`, so a destructive 
retention delete can be
      confirmed twice into two concurrent backend calls and can still be 
dismissed with the X, Esc,
      the mask or Cancel while the request runs, leaving the UI looking 
cancelled while the delete
      continues — the export button on the same page is already locked 
(`:303-312`, `:510`), so this
      was the one write entry left unguarded; adds a synchronous 
`cleanupInFlightRef` gate plus a
      `cleaning` state, wires `confirmLoading`, disables close / keyboard / 
mask / cancel while the
      request is in flight, makes `onCancel` a no-op in that window, and resets 
both in `finally`.
    
    Three new cases, one per member, all mutation-sensitive: restoring `found ? 
found.yaml : ''`
    makes `exportAlertRuleAsset('does-not-exist')` resolve instead of rejecting 
with the 404 text,
    removing either ACL throw fails the matching `rejects.toThrow`, and 
removing the cleanup gate lets
    both clicks of a double-click into `cleanupAuditLogs`, failing 
`toHaveBeenCalledTimes(1)` together
    with the `ant-btn-loading` class assertion. Two of them assert more than 
the throw or the lock:
    the ACL case snapshots `listAclRules()` / `listAclUsers()` before and 
after, so a
    splice-then-throw implementation also fails, and the audit case checks that 
the filter options
    still refresh once a cleanup succeeds, so the new gate cannot pass by 
swallowing the reload.
    
    Folded: #6042, #6043 were closed as folded into this commit (same author).
---
 web/src/pages/ops/__tests__/AuditPage.test.tsx | 24 ++++++++++++++++++++++++
 web/src/pages/ops/audit.tsx                    | 17 ++++++++++++++++-
 web/src/services/aclService.test.ts            | 13 +++++++++++++
 web/src/services/aclService.ts                 |  6 ++++--
 web/src/services/alertRuleAssetService.test.ts |  8 ++++++++
 web/src/services/alertRuleAssetService.ts      | 18 ++++++++++--------
 6 files changed, 75 insertions(+), 11 deletions(-)

diff --git a/web/src/pages/ops/__tests__/AuditPage.test.tsx 
b/web/src/pages/ops/__tests__/AuditPage.test.tsx
index 4aa7c1d85..fc5fd5108 100644
--- a/web/src/pages/ops/__tests__/AuditPage.test.tsx
+++ b/web/src/pages/ops/__tests__/AuditPage.test.tsx
@@ -389,4 +389,28 @@ describe('Audit page', () => {
 
     await waitFor(() => 
expect(opsService.getAuditFilterOptions).toHaveBeenCalledTimes(2));
   });
+
+  it('submits audit cleanup only once while the request is pending', async () 
=> {
+    const user = userEvent.setup();
+    let resolveCleanup: (deleted: number) => void = () => undefined;
+    vi.mocked(opsService.cleanupAuditLogs).mockImplementation(
+      () =>
+        new Promise<number>((resolve) => {
+          resolveCleanup = resolve;
+        }),
+    );
+
+    renderWithProviders(<AuditPage />);
+
+    expect(await screen.findByText('topic-a')).toBeInTheDocument();
+    await user.click(screen.getByRole('button', { name: '清理日志' }));
+    const confirmButton = await screen.findByRole('button', { name: '确认清理' });
+    await user.dblClick(confirmButton);
+
+    expect(opsService.cleanupAuditLogs).toHaveBeenCalledTimes(1);
+    expect(confirmButton).toHaveClass('ant-btn-loading');
+
+    resolveCleanup(3);
+    await waitFor(() => 
expect(opsService.getAuditFilterOptions).toHaveBeenCalledTimes(2));
+  });
 });
diff --git a/web/src/pages/ops/audit.tsx b/web/src/pages/ops/audit.tsx
index d33fd79ca..498e70402 100644
--- a/web/src/pages/ops/audit.tsx
+++ b/web/src/pages/ops/audit.tsx
@@ -106,12 +106,14 @@ const AuditPage: React.FC = () => {
   const [filterOptions, setFilterOptions] = 
useState<AuditFilterOptions>(emptyFilterOptions);
   const [cleanupModalOpen, setCleanupModalOpen] = useState(false);
   const [cleanupDays, setCleanupDays] = useState(30);
+  const [cleaning, setCleaning] = useState(false);
   const [exporting, setExporting] = useState(false);
   const [summary, setSummary] = useState<AuditSummary | null>(null);
   const [summaryLoading, setSummaryLoading] = useState(true);
   const [timelineResource, setTimelineResource] = 
useState<AuditTimelineResource | null>(null);
   const recordsRequestRef = useRef(0);
   const filterOptionsRequestRef = useRef(0);
+  const cleanupInFlightRef = useRef(false);
 
   useEffect(() => {
     const requestId = ++filterOptionsRequestRef.current;
@@ -289,6 +291,9 @@ const AuditPage: React.FC = () => {
   };
 
   const handleCleanup = async () => {
+    if (cleanupInFlightRef.current) return;
+    cleanupInFlightRef.current = true;
+    setCleaning(true);
     try {
       await cleanupAuditLogs(cleanupDays);
       setPage(1);
@@ -297,6 +302,9 @@ const AuditPage: React.FC = () => {
       setCleanupModalOpen(false);
     } catch {
       message.error('清理审计日志失败,请稍后重试');
+    } finally {
+      cleanupInFlightRef.current = false;
+      setCleaning(false);
     }
   };
 
@@ -562,9 +570,16 @@ const AuditPage: React.FC = () => {
         title={t('audit.cleanupTitle')}
         open={cleanupModalOpen}
         onOk={handleCleanup}
-        onCancel={() => setCleanupModalOpen(false)}
+        onCancel={() => {
+          if (!cleanupInFlightRef.current) setCleanupModalOpen(false);
+        }}
         okText={t('audit.cleanupConfirm')}
         cancelText={t('common.cancel')}
+        confirmLoading={cleaning}
+        closable={!cleaning}
+        keyboard={!cleaning}
+        maskClosable={!cleaning}
+        cancelButtonProps={{ disabled: cleaning }}
         okButtonProps={{ danger: true }}
       >
         <Flex vertical gap={12}>
diff --git a/web/src/services/aclService.test.ts 
b/web/src/services/aclService.test.ts
index 6aae08c4a..5a40e1487 100644
--- a/web/src/services/aclService.test.ts
+++ b/web/src/services/aclService.test.ts
@@ -20,6 +20,8 @@ import {
   createAclRule,
   createAclUser,
   createAndUpdatePlainAccessConfig,
+  deleteAclRule,
+  deleteAclUser,
   examineBrokerClusterAclConfig,
   listAclRules,
   listAclUsers,
@@ -153,4 +155,15 @@ describe('ACL service mock data', () => {
       'secretKey is required',
     );
   });
+
+  it('rejects deletion of missing ACL records', async () => {
+    const rulesBefore = await listAclRules();
+    const usersBefore = await listAclUsers();
+
+    await expect(deleteAclRule('missing-rule')).rejects.toThrow('ACL rule not 
found: missing-rule');
+    await expect(deleteAclUser('missing-user')).rejects.toThrow('ACL user not 
found: missing-user');
+
+    await expect(listAclRules()).resolves.toEqual(rulesBefore);
+    await expect(listAclUsers()).resolves.toEqual(usersBefore);
+  });
 });
diff --git a/web/src/services/aclService.ts b/web/src/services/aclService.ts
index 877f6c182..2b5084376 100644
--- a/web/src/services/aclService.ts
+++ b/web/src/services/aclService.ts
@@ -165,7 +165,8 @@ export async function updateAclRule(
 export async function deleteAclRule(id: AclEntityId, instanceId?: string): 
Promise<void> {
   if (isMockMode()) {
     const idx = aclRulesState.findIndex((rule) => rule.id === id);
-    if (idx >= 0) aclRulesState.splice(idx, 1);
+    if (idx < 0) throw new Error(`ACL rule not found: ${id}`);
+    aclRulesState.splice(idx, 1);
     return;
   }
   return aclApi.deleteAclRule(id, instanceId);
@@ -210,7 +211,8 @@ export async function updateAclUser(
 export async function deleteAclUser(id: AclEntityId, instanceId?: string): 
Promise<void> {
   if (isMockMode()) {
     const idx = aclUsersState.findIndex((user) => user.id === id);
-    if (idx >= 0) aclUsersState.splice(idx, 1);
+    if (idx < 0) throw new Error(`ACL user not found: ${id}`);
+    aclUsersState.splice(idx, 1);
     return;
   }
   return aclApi.deleteAclUser(id, instanceId);
diff --git a/web/src/services/alertRuleAssetService.test.ts 
b/web/src/services/alertRuleAssetService.test.ts
index 2c545f713..34d545d77 100644
--- a/web/src/services/alertRuleAssetService.test.ts
+++ b/web/src/services/alertRuleAssetService.test.ts
@@ -73,6 +73,14 @@ describe('alertRuleAssetService (mock mode)', () => {
     expect(blob).toBeInstanceOf(Blob);
     expect(blob.type).toBe('text/yaml');
   });
+
+  it('rejects an export for an unknown asset', async () => {
+    mode.mock = true;
+
+    await expect(exportAlertRuleAsset('does-not-exist')).rejects.toThrow(
+      'Alert rule asset not found: does-not-exist',
+    );
+  });
 });
 
 describe('alertRuleAssetService (real mode)', () => {
diff --git a/web/src/services/alertRuleAssetService.ts 
b/web/src/services/alertRuleAssetService.ts
index cd82389fa..db8d296dc 100644
--- a/web/src/services/alertRuleAssetService.ts
+++ b/web/src/services/alertRuleAssetService.ts
@@ -6,6 +6,14 @@ import * as alertRuleAssetsApi from '../api/alertRuleAssets';
 import type { AlertRuleAssetInfo } from '../api/alertRuleAssets';
 import { mockAlertRuleAssets } from '../mock/alertRuleAssets';
 
+function getMockAlertRuleAsset(name: string) {
+  const found = mockAlertRuleAssets.find((asset) => asset.name === name);
+  if (!found) {
+    throw new Error(`Alert rule asset not found: ${name}`);
+  }
+  return found;
+}
+
 export async function listAlertRuleAssets(): Promise<AlertRuleAssetInfo[]> {
   if (isMockMode()) {
     return mockAlertRuleAssets.map(({ name, group, ruleCount, severities }) => 
({
@@ -20,20 +28,14 @@ export async function listAlertRuleAssets(): 
Promise<AlertRuleAssetInfo[]> {
 
 export async function getAlertRuleAsset(name: string): Promise<string> {
   if (isMockMode()) {
-    const found = mockAlertRuleAssets.find((asset) => asset.name === name);
-    if (!found) {
-      throw new Error(`Alert rule asset not found: ${name}`);
-    }
-    return found.yaml;
+    return getMockAlertRuleAsset(name).yaml;
   }
   return alertRuleAssetsApi.getAlertRuleAsset(name);
 }
 
 export async function exportAlertRuleAsset(name: string): Promise<Blob> {
   if (isMockMode()) {
-    const found = mockAlertRuleAssets.find((asset) => asset.name === name);
-    const yaml = found ? found.yaml : '';
-    return new Blob([yaml], { type: 'text/yaml' });
+    return new Blob([getMockAlertRuleAsset(name).yaml], { type: 'text/yaml' });
   }
   return alertRuleAssetsApi.exportAlertRuleAsset(name);
 }

Reply via email to