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();
+ });
});