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 538facc87 feat(credential): export the filtered cloud credential 
inventory as masked CSV (#3980)
538facc87 is described below

commit 538facc875b480c7693f91c1de1e5bdb4ed0cd20
Author: 烤化の初雪 <[email protected]>
AuthorDate: Mon Sep 7 17:04:09 2026 +0800

    feat(credential): export the filtered cloud credential inventory as masked 
CSV (#3980)
    
    Adds a GET /api/cloud-credentials/export endpoint that renders the
    credential inventory matching the current vendor and search filters as
    CSV (name, vendor, masked access key, remark, timestamps), reusing the
    shared CsvUtil quoting/injection guard and the audit export bound
    pattern. Secrets are never exported: rows go through the same
    maskAccessKey path as the list API and the bound rejects oversized
    exports with a 400.
    
    The settings credentials page gains an export button that downloads the
    backend CSV with the active filters applied.
    
    Co-authored-by: unbridled-41 
<[email protected]>
---
 .../credential/CloudCredentialController.java      |  6 ++++
 .../credential/CloudCredentialService.java         | 20 +++++++++++
 .../credential/CloudCredentialControllerTest.java  | 25 ++++++++++++++
 .../credential/CloudCredentialServiceTest.java     | 32 +++++++++++++++++
 web/src/api/cloudCredential.test.ts                | 13 +++++++
 web/src/api/cloudCredential.ts                     | 10 ++++++
 web/src/i18n/translations.ts                       |  4 +++
 web/src/pages/settings/CloudCredentialTab.tsx      | 40 +++++++++++++++++++---
 .../settings/__tests__/CloudCredentialTab.test.tsx | 36 +++++++++++++++++++
 9 files changed, 182 insertions(+), 4 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialController.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialController.java
index e657df96c..c54fce087 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialController.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialController.java
@@ -49,6 +49,12 @@ public class CloudCredentialController {
         return Result.ok(credentialService.listMasked(vendor, search, page, 
pageSize));
     }
 
+    @GetMapping("/export")
+    public Result<String> exportCredentials(@RequestParam(required = false) 
InstanceVendor vendor,
+            @RequestParam(required = false) String search) {
+        return Result.ok(credentialService.exportMaskedCsv(vendor, search));
+    }
+
     @PostMapping("/create")
     public Result<CloudCredentialVO> createCredential(
             @Valid @RequestBody(required = false) CreateCloudCredentialDTO 
request) {
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialService.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialService.java
index 3fe023f03..505b2c107 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialService.java
@@ -22,6 +22,7 @@ import 
org.apache.rocketmq.studio.common.exception.BusinessException;
 import org.apache.rocketmq.studio.common.domain.PageResult;
 import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
 import org.apache.rocketmq.studio.common.util.CredentialUtils;
+import org.apache.rocketmq.studio.common.util.CsvUtil;
 import org.apache.rocketmq.studio.audit.OperationAuditService;
 import org.apache.rocketmq.studio.instance.InstanceRepository;
 import org.apache.rocketmq.studio.provider.alibaba.AliyunClientFactory;
@@ -40,6 +41,10 @@ import java.util.List;
 @Service
 public class CloudCredentialService {
 
+    private static final int MAX_EXPORT_CREDENTIALS = 10_000;
+    private static final String EXPORT_CSV_HEADER =
+            "Name,Vendor,Access Key,Remark,Created,Modified\r\n";
+
     private final CloudCredentialRepository credentialRepository;
     private final InstanceRepository instanceRepository;
     private final AliyunClientFactory aliyunClientFactory;
@@ -58,6 +63,21 @@ public class CloudCredentialService {
         return 
PageResult.of(result.getItems().stream().map(this::maskAccessKey).toList(), 
result.getTotal(), page, pageSize);
     }
 
+    public String exportMaskedCsv(InstanceVendor vendor, String search) {
+        PageResult<CloudCredentialVO> result = 
credentialRepository.findPage(vendor, search, 1, MAX_EXPORT_CREDENTIALS);
+        if (result.getTotal() > MAX_EXPORT_CREDENTIALS) {
+            throw new BusinessException(400, "Cloud credential export exceeds 
the maximum of "
+                    + MAX_EXPORT_CREDENTIALS + " records; narrow the filters");
+        }
+        StringBuilder csv = new 
StringBuilder("\uFEFF").append(EXPORT_CSV_HEADER);
+        for (CloudCredentialVO credential : result.getItems()) {
+            CloudCredentialVO masked = maskAccessKey(credential);
+            CsvUtil.appendRow(csv, masked.getName(), masked.getVendor(), 
masked.getAccessKey(),
+                    masked.getRemark(), masked.getGmtCreate(), 
masked.getGmtModified());
+        }
+        return csv.toString();
+    }
+
     public CloudCredentialVO create(CloudCredentialVO credential) {
         if (credential == null) {
             throw new BusinessException(400, "Cloud credential request is 
required");
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialControllerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialControllerTest.java
index cd668be95..1b0cd0acc 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialControllerTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialControllerTest.java
@@ -16,6 +16,7 @@
  */
 package org.apache.rocketmq.studio.provider.credential;
 
+import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
 import org.junit.jupiter.api.Test;
 import org.springframework.beans.factory.annotation.Autowired;
 import 
org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
@@ -24,9 +25,11 @@ import org.springframework.boot.test.mock.mockito.MockBean;
 import org.springframework.http.HttpHeaders;
 import org.springframework.test.web.servlet.MockMvc;
 
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 import static 
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
 import static 
org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
+import static 
org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
 import static 
org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
 
 @WebMvcTest(CloudCredentialController.class)
@@ -51,4 +54,26 @@ class CloudCredentialControllerTest {
                 .andExpect(status().isOk())
                 .andExpect(header().string(HttpHeaders.CACHE_CONTROL, 
"no-store"));
     }
+
+    @Test
+    void exportCredentialsShouldReturnCsvEnvelope() throws Exception {
+        when(credentialService.exportMaskedCsv(null, 
null)).thenReturn("\"Name\",\"Vendor\"\r\n");
+
+        mockMvc.perform(get("/api/cloud-credentials/export"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                
.andExpect(jsonPath("$.data").value("\"Name\",\"Vendor\"\r\n"));
+    }
+
+    @Test
+    void exportCredentialsShouldPassVendorAndSearchFilters() throws Exception {
+        when(credentialService.exportMaskedCsv(InstanceVendor.ALIYUN, 
"prod")).thenReturn("csv");
+
+        mockMvc.perform(get("/api/cloud-credentials/export")
+                        .param("vendor", "ALIYUN")
+                        .param("search", "prod"))
+                .andExpect(status().isOk());
+
+        verify(credentialService).exportMaskedCsv(InstanceVendor.ALIYUN, 
"prod");
+    }
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialServiceTest.java
index 05ebadda8..82fd069bd 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialServiceTest.java
@@ -16,6 +16,7 @@
  */
 package org.apache.rocketmq.studio.provider.credential;
 
+import org.apache.rocketmq.studio.common.domain.PageResult;
 import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
 import org.apache.rocketmq.studio.common.util.CredentialUtils;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
@@ -39,6 +40,7 @@ import static 
org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.argThat;
 import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
@@ -293,4 +295,34 @@ class CloudCredentialServiceTest {
     void repositoryShouldTolerateLegacyPlainSecretTest() {
         assertThat(CredentialUtils.decodeBase64("not base64 
!!!")).isEqualTo("not base64 !!!");
     }
+
+    @Test
+    void exportShouldRenderMaskedCsvWithoutSecretsTest() {
+        CloudCredentialVO stored = new CloudCredentialVO();
+        stored.setId(1L);
+        stored.setName("aliyun-test");
+        stored.setVendor(InstanceVendor.ALIYUN);
+        stored.setAccessKey("LTAI5tUnitTestKey000000001");
+        stored.setSecretKey("secret-value");
+        stored.setRemark("rotation pending");
+        when(credentialRepository.findPage(InstanceVendor.ALIYUN, "prod", 1, 
10_000))
+                .thenReturn(PageResult.of(List.of(stored), 1, 1, 10_000));
+
+        String csv = service.exportMaskedCsv(InstanceVendor.ALIYUN, "prod");
+
+        assertThat(csv).startsWith("\uFEFFName,Vendor,Access 
Key,Remark,Created,Modified\r\n");
+        
assertThat(csv).contains("\"aliyun-test\",\"ALIYUN\",\"LTAI****0001\",\"rotation
 pending\"");
+        assertThat(csv).doesNotContain("secret-value");
+        assertThat(csv).doesNotContain("LTAI5tUnitTestKey000000001");
+    }
+
+    @Test
+    void exportShouldRejectResultBeyondBoundTest() {
+        when(credentialRepository.findPage(isNull(), isNull(), eq(1), 
eq(10_000)))
+                .thenReturn(PageResult.of(List.of(), 10_001, 1, 10_000));
+
+        assertThatThrownBy(() -> service.exportMaskedCsv(null, null))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("narrow the filters");
+    }
 }
diff --git a/web/src/api/cloudCredential.test.ts 
b/web/src/api/cloudCredential.test.ts
index ea380f31a..35337e6c9 100644
--- a/web/src/api/cloudCredential.test.ts
+++ b/web/src/api/cloudCredential.test.ts
@@ -21,6 +21,7 @@ import client from './client';
 import {
   createCloudCredential,
   deleteCloudCredential,
+  exportCloudCredentials,
   listCloudCredentials,
   updateCloudCredential,
 } from './cloudCredential';
@@ -113,4 +114,16 @@ describe('cloudCredential API', () => {
     await deleteCloudCredential(1);
     expect(JSON.parse(mock.history.post[1].data)).toEqual({ id: '1' });
   });
+
+  it('returns the backend CSV export with the active filters', async () => {
+    mock.onGet('/cloud-credentials/export').reply(200, {
+      code: 200,
+      data: '"Name","Vendor"\r\n"aliyun-test","ALIYUN"\r\n',
+    });
+
+    const csv = await exportCloudCredentials('ALIYUN', 'prod');
+
+    expect(csv).toContain('aliyun-test');
+    expect(mock.history.get[0].params).toMatchObject({ vendor: 'ALIYUN', 
search: 'prod' });
+  });
 });
diff --git a/web/src/api/cloudCredential.ts b/web/src/api/cloudCredential.ts
index e08a357e2..5a412d374 100644
--- a/web/src/api/cloudCredential.ts
+++ b/web/src/api/cloudCredential.ts
@@ -63,3 +63,13 @@ export async function updateCloudCredential(request: 
UpdateCloudCredentialReques
 export async function deleteCloudCredential(id: number) {
   await client.post('/cloud-credentials/delete', { id: String(id) });
 }
+
+export async function exportCloudCredentials(
+  vendor?: InstanceVendor,
+  search?: string,
+): Promise<string> {
+  const res = await client.get<{ data: string }>('/cloud-credentials/export', {
+    params: { vendor, search },
+  });
+  return res.data.data;
+}
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 7408f2e78..85c342f66 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -1409,6 +1409,10 @@ const translations: Record<string, Record<Lang, string>> 
= {
     zh: '云凭据加载失败,请稍后重试',
     en: 'Failed to load cloud credentials. Please try again later.',
   },
+  'settings.credentialExportFailed': {
+    zh: '导出云凭据失败,请稍后重试',
+    en: 'Failed to export cloud credentials. Please try again later.',
+  },
   'settings.credentialUpdated': { zh: '云凭据已更新', en: 'Cloud credential updated' 
},
   'settings.credentialAdded': { zh: '云凭据已添加', en: 'Cloud credential added' },
   'settings.credentialSaveFailed': {
diff --git a/web/src/pages/settings/CloudCredentialTab.tsx 
b/web/src/pages/settings/CloudCredentialTab.tsx
index 295d519f0..f4482bde5 100644
--- a/web/src/pages/settings/CloudCredentialTab.tsx
+++ b/web/src/pages/settings/CloudCredentialTab.tsx
@@ -30,7 +30,7 @@ import {
   Tag,
   message,
 } from 'antd';
-import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons';
+import { DeleteOutlined, DownloadOutlined, EditOutlined, PlusOutlined } from 
'@ant-design/icons';
 import type { ColumnsType } from 'antd/es/table';
 import { MagnifyingGlass } from '@phosphor-icons/react';
 import { useLang } from '../../i18n/LangContext';
@@ -38,11 +38,13 @@ import { useLang } from '../../i18n/LangContext';
 import {
   createCloudCredential,
   deleteCloudCredential,
+  exportCloudCredentials,
   listCloudCredentials,
   updateCloudCredential,
 } from '../../api/cloudCredential';
 import type { CloudCredential } from '../../api/cloudCredential';
 import type { InstanceVendor } from '../../api/instance';
+import { downloadBlob } from '../../utils/download';
 
 const vendorTagColor: Record<string, string> = {
   ALIYUN: 'orange',
@@ -73,6 +75,7 @@ export const CloudCredentialTab = () => {
   const [editingCredential, setEditingCredential] = useState<CloudCredential | 
null>(null);
   const [form] = Form.useForm<CredentialFormValues>();
   const [submitting, setSubmitting] = useState(false);
+  const [exporting, setExporting] = useState(false);
   const requestSeqRef = useRef(0);
   const submitInFlightRef = useRef(false);
 
@@ -134,6 +137,21 @@ export const CloudCredentialTab = () => {
     setPage(1);
   };
 
+  const handleExport = useCallback(async () => {
+    setExporting(true);
+    try {
+      const csv = await exportCloudCredentials(vendorFilter, debouncedSearch);
+      downloadBlob(
+        new Blob([csv], { type: 'text/csv;charset=utf-8' }),
+        `rocketmq-cloud-credentials-${new Date().toISOString().slice(0, 
10)}.csv`,
+      );
+    } catch {
+      message.error(t('settings.credentialExportFailed'));
+    } finally {
+      setExporting(false);
+    }
+  }, [debouncedSearch, t, vendorFilter]);
+
   const resetModal = () => {
     setModalOpen(false);
     setEditingCredential(null);
@@ -285,9 +303,23 @@ export const CloudCredentialTab = () => {
             ]}
           />
         </Flex>
-        <Button type="primary" icon={<PlusOutlined />} 
onClick={openCreateModal} disabled={loading}>
-          {t('settings.addCredential')}
-        </Button>
+        <Flex gap={8}>
+          <Button
+            icon={<DownloadOutlined />}
+            loading={exporting}
+            onClick={() => void handleExport()}
+          >
+            {t('common.export')}
+          </Button>
+          <Button
+            type="primary"
+            icon={<PlusOutlined />}
+            onClick={openCreateModal}
+            disabled={loading}
+          >
+            {t('settings.addCredential')}
+          </Button>
+        </Flex>
       </Flex>
 
       <Table<CloudCredential>
diff --git a/web/src/pages/settings/__tests__/CloudCredentialTab.test.tsx 
b/web/src/pages/settings/__tests__/CloudCredentialTab.test.tsx
index cc58753ab..ae4e045be 100644
--- a/web/src/pages/settings/__tests__/CloudCredentialTab.test.tsx
+++ b/web/src/pages/settings/__tests__/CloudCredentialTab.test.tsx
@@ -19,10 +19,12 @@ import { beforeAll, beforeEach, describe, expect, it, vi } 
from 'vitest';
 import { fireEvent, render, screen, waitFor, within } from 
'@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { App } from 'antd';
+import { downloadBlob } from '../../../utils/download';
 import type { CloudCredentialPage } from '../../../api/cloudCredential';
 import {
   createCloudCredential,
   deleteCloudCredential,
+  exportCloudCredentials,
   listCloudCredentials,
   updateCloudCredential,
 } from '../../../api/cloudCredential';
@@ -33,10 +35,15 @@ import { CloudCredentialTab } from '../CloudCredentialTab';
 vi.mock('../../../api/cloudCredential', () => ({
   createCloudCredential: vi.fn(),
   deleteCloudCredential: vi.fn(),
+  exportCloudCredentials: vi.fn(),
   listCloudCredentials: vi.fn(),
   updateCloudCredential: vi.fn(),
 }));
 
+vi.mock('../../../utils/download', () => ({
+  downloadBlob: vi.fn(),
+}));
+
 const credentials: CloudCredentialPage = {
   items: [
     {
@@ -286,4 +293,33 @@ describe('CloudCredentialTab', () => {
       expect(listCloudCredentials).toHaveBeenLastCalledWith(undefined, '', 1, 
20),
     );
   });
+
+  it('downloads the backend CSV export with the active filters', async () => {
+    vi.mocked(exportCloudCredentials).mockResolvedValue(
+      '"Name","Vendor"\r\n"aliyun-test","ALIYUN"\r\n',
+    );
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    renderTab();
+    await waitFor(() => expect(listCloudCredentials).toHaveBeenCalled());
+
+    await user.click(screen.getByRole('button', { name: /导出/ }));
+
+    await waitFor(() => 
expect(exportCloudCredentials).toHaveBeenCalledWith(undefined, ''));
+    expect(downloadBlob).toHaveBeenCalledTimes(1);
+    const [blob, filename] = vi.mocked(downloadBlob).mock.calls[0];
+    expect((blob as Blob).type).toBe('text/csv;charset=utf-8');
+    
expect(filename).toMatch(/^rocketmq-cloud-credentials-\d{4}-\d{2}-\d{2}\.csv$/);
+  });
+
+  it('reports an export failure instead of downloading an empty file', async 
() => {
+    vi.mocked(exportCloudCredentials).mockRejectedValue(new Error('boom'));
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    renderTab();
+    await waitFor(() => expect(listCloudCredentials).toHaveBeenCalled());
+
+    await user.click(screen.getByRole('button', { name: /导出/ }));
+
+    expect(await screen.findByText('导出云凭据失败,请稍后重试')).toBeInTheDocument();
+    expect(downloadBlob).not.toHaveBeenCalled();
+  });
 });

Reply via email to