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 c4c2d96e fix: harden web service contracts (#924)
c4c2d96e is described below
commit c4c2d96e348dd2a8b691a29f32e4cdb0f71d8045
Author: aias00 <[email protected]>
AuthorDate: Tue Aug 4 03:03:26 2026 -0700
fix: harden web service contracts (#924)
* [ISSUE #909] Guard stale cluster store refreshes
Signed-off-by: liuhy <[email protected]>
* [ISSUE #911] Trim consumer group search text
Signed-off-by: liuhy <[email protected]>
* [ISSUE #913] Reject missing alert rule updates
Signed-off-by: liuhy <[email protected]>
* fix(web): copy k8s cert sans before storing
Signed-off-by: liuhy <[email protected]>
* fix(web): reject duplicate nameserver edits
Signed-off-by: liuhy <[email protected]>
* fix(web): reject duplicate topic creates
Signed-off-by: liuhy <[email protected]>
* fix(web): reject missing mock instance deletes
Signed-off-by: liuhy <[email protected]>
* fix(web): filter mock messages by time range
Signed-off-by: liuhy <[email protected]>
---------
Signed-off-by: liuhy <[email protected]>
---
web/src/services/clusterService.test.ts | 70 +++++++++++++++++++++++++++++++-
web/src/services/clusterService.ts | 13 +++---
web/src/services/consumerService.test.ts | 13 ++++++
web/src/services/consumerService.ts | 4 +-
web/src/services/instanceService.test.ts | 12 +++++-
web/src/services/instanceService.ts | 3 +-
web/src/services/messageService.test.ts | 12 ++++++
web/src/services/messageService.ts | 13 ++++++
web/src/services/opsService.test.ts | 17 ++++++++
web/src/services/opsService.ts | 3 +-
web/src/services/topicService.test.ts | 22 +++++++++-
web/src/services/topicService.ts | 5 +++
web/src/stores/clusterStore.test.ts | 44 ++++++++++++++++++++
web/src/stores/clusterStore.ts | 11 ++++-
14 files changed, 228 insertions(+), 14 deletions(-)
diff --git a/web/src/services/clusterService.test.ts
b/web/src/services/clusterService.test.ts
index 555c45f3..1d9e0ed6 100644
--- a/web/src/services/clusterService.test.ts
+++ b/web/src/services/clusterService.test.ts
@@ -22,7 +22,16 @@ vi.mock('../config', () => ({
API_BASE_URL: '/api',
}));
-import { getCluster, listClusters, updateClusterConfig } from
'./clusterService';
+import {
+ createK8sCert,
+ deleteK8sCert,
+ getCluster,
+ listClusters,
+ listK8sCerts,
+ updateClusterConfig,
+ updateK8sCert,
+ updateNameServer,
+} from './clusterService';
describe('clusterService mock clusters', () => {
it('returns defensive copies from cluster detail reads', async () => {
@@ -85,4 +94,63 @@ describe('clusterService mock clusters', () => {
});
}
});
+
+ it('rejects NameServer edits that would duplicate an address in the same
cluster', async () => {
+ const clusterId = 'cluster-prod';
+ const original = await getCluster(clusterId);
+ const [first, second] = original.nameServers;
+
+ try {
+ await expect(
+ updateNameServer({
+ clusterId,
+ addr: first.addr,
+ newAddr: second.addr,
+ }),
+ ).rejects.toThrow(`NameServer already exists: ${second.addr}`);
+
+ const fresh = await getCluster(clusterId);
+ expect(fresh.nameServers.map((item) => item.addr)).toEqual(
+ original.nameServers.map((item) => item.addr),
+ );
+ } finally {
+ const current = await getCluster(clusterId);
+ for (let index = 0; index < original.nameServers.length; index += 1) {
+ const currentAddr = current.nameServers[index]?.addr;
+ const originalAddr = original.nameServers[index].addr;
+ if (currentAddr && currentAddr !== originalAddr) {
+ await updateNameServer({ clusterId, addr: currentAddr, newAddr:
originalAddr });
+ }
+ }
+ }
+ });
+
+ it('copies certificate SAN arrays before writing them into the mock store',
async () => {
+ const san = ['proxy.example.com'];
+ const created = await createK8sCert({
+ name: 'cert-copy-test',
+ namespace: 'rocketmq',
+ cluster: 'cluster-prod',
+ san,
+ });
+
+ try {
+ san.push('mutated-create.example.com');
+
+ let stored = (await listK8sCerts()).find((cert) => cert.id ===
created.id);
+ expect(stored?.san).toEqual(['proxy.example.com']);
+
+ const nextSan = ['proxy-next.example.com'];
+ await updateK8sCert({
+ id: created.id,
+ san: nextSan,
+ });
+ nextSan.push('mutated-update.example.com');
+
+ stored = (await listK8sCerts()).find((cert) => cert.id === created.id);
+ expect(stored?.san).toEqual(['proxy-next.example.com']);
+ } finally {
+ await deleteK8sCert(created.id);
+ }
+ });
});
diff --git a/web/src/services/clusterService.ts
b/web/src/services/clusterService.ts
index de6677dd..601f9f22 100644
--- a/web/src/services/clusterService.ts
+++ b/web/src/services/clusterService.ts
@@ -82,7 +82,7 @@ export async function createK8sCert(data:
Partial<K8sCertInfo>): Promise<K8sCert
notAfter: notAfter.toISOString(),
status: 'valid',
daysRemaining: 365,
- san: data.san ?? [],
+ san: [...(data.san ?? [])],
};
mockCertStore.push(cert);
return { ...cert, san: [...cert.san] };
@@ -94,7 +94,7 @@ export async function updateK8sCert(data:
Partial<K8sCertInfo>): Promise<K8sCert
if (isMockMode()) {
const existing = mockCertStore.find((cert) => cert.id === data.id);
if (!existing) throw new Error(`Certificate not found: ${data.id}`);
- Object.assign(existing, data, { san: data.san ?? existing.san });
+ Object.assign(existing, data, { san: data.san ? [...data.san] :
existing.san });
return { ...existing, san: [...existing.san] };
}
return clusterApi.updateK8sCert(data);
@@ -204,10 +204,13 @@ export async function updateNameServer(data: {
newAddr?: string;
}): Promise<void> {
if (isMockMode()) {
- const nameServer = getMockCluster(data.clusterId).nameServers.find(
- (item) => item.addr === data.addr,
- );
+ const nameServers = getMockCluster(data.clusterId).nameServers;
+ const nameServer = nameServers.find((item) => item.addr === data.addr);
if (!nameServer) throw new Error(`NameServer not found: ${data.addr}`);
+ if (data.newAddr && data.newAddr !== data.addr) {
+ const duplicate = nameServers.some((item) => item !== nameServer &&
item.addr === data.newAddr);
+ if (duplicate) throw new Error(`NameServer already exists:
${data.newAddr}`);
+ }
if (data.newAddr) nameServer.addr = data.newAddr;
return;
}
diff --git a/web/src/services/consumerService.test.ts
b/web/src/services/consumerService.test.ts
index d5d637cf..4d61b45d 100644
--- a/web/src/services/consumerService.test.ts
+++ b/web/src/services/consumerService.test.ts
@@ -55,6 +55,19 @@ describe('consumer service mock data', () => {
expect(second.instances[0]).not.toBe(first.instances[0]);
});
+ it('trims search text before filtering consumer group names', async () => {
+ const groups = await listConsumerGroups({ search: ' CG-ORDER-NOTIFY ' });
+
+ expect(groups.map((group) => group.name)).toEqual(['cg-order-notify']);
+ });
+
+ it('ignores blank search text', async () => {
+ const allGroups = await listConsumerGroups();
+ const blankSearchGroups = await listConsumerGroups({ search: ' ' });
+
+ expect(blankSearchGroups).toHaveLength(allGroups.length);
+ });
+
it('returns copied progress and subscription rows', async () => {
const firstProgress = await getConsumerProgress('cg-order-notify');
const firstSubscriptions = await
getConsumerSubscriptions('cg-order-notify');
diff --git a/web/src/services/consumerService.ts
b/web/src/services/consumerService.ts
index 6cfc0678..256c1b0c 100644
--- a/web/src/services/consumerService.ts
+++ b/web/src/services/consumerService.ts
@@ -43,8 +43,8 @@ export async function listConsumerGroups(params?:
ConsumerGroupQuery): Promise<C
let result = [...consumerGroupsState];
if (params?.clusterId) result = result.filter((group) => group.clusterId
=== params.clusterId);
if (params?.search) {
- const kw = params.search.toLowerCase();
- result = result.filter((g) => g.name.toLowerCase().includes(kw));
+ const kw = params.search.trim().toLowerCase();
+ if (kw) result = result.filter((g) => g.name.toLowerCase().includes(kw));
}
return result.map(copyConsumerGroup);
}
diff --git a/web/src/services/instanceService.test.ts
b/web/src/services/instanceService.test.ts
index 5d285f05..5b8edb95 100644
--- a/web/src/services/instanceService.test.ts
+++ b/web/src/services/instanceService.test.ts
@@ -22,7 +22,7 @@ vi.mock('../config', () => ({
API_BASE_URL: '/api',
}));
-import { createInstance, listInstances, updateInstance } from
'./instanceService';
+import { createInstance, deleteInstance, listInstances, updateInstance } from
'./instanceService';
describe('instanceService mock instances', () => {
it('returns defensive copies from list reads', async () => {
@@ -82,4 +82,14 @@ describe('instanceService mock instances', () => {
const storedUpdated = afterUpdate.find((instance) => instance.id ===
created.id);
expect(storedUpdated?.remark).toBe('updated');
});
+
+ it('rejects deleting missing mock instances', async () => {
+ const before = await listInstances();
+
+ await expect(deleteInstance('missing-instance')).rejects.toThrow(
+ 'Instance not found: missing-instance',
+ );
+
+ await expect(listInstances()).resolves.toEqual(before);
+ });
});
diff --git a/web/src/services/instanceService.ts
b/web/src/services/instanceService.ts
index d162c160..54121765 100644
--- a/web/src/services/instanceService.ts
+++ b/web/src/services/instanceService.ts
@@ -65,7 +65,8 @@ export async function updateInstance(data:
UpdateInstanceRequest): Promise<Insta
export async function deleteInstance(id: string): Promise<void> {
if (isMockMode()) {
const idx = mockInstances.findIndex((i) => i.id === id);
- if (idx >= 0) mockInstances.splice(idx, 1);
+ if (idx < 0) throw new Error(`Instance not found: ${id}`);
+ mockInstances.splice(idx, 1);
return;
}
return instanceApi.deleteInstance(id);
diff --git a/web/src/services/messageService.test.ts
b/web/src/services/messageService.test.ts
index 3e010f01..377eb5d5 100644
--- a/web/src/services/messageService.test.ts
+++ b/web/src/services/messageService.test.ts
@@ -39,6 +39,18 @@ describe('message service mock data', () => {
expect(second[0].properties).not.toBe(first[0].properties);
});
+ it('filters mock topic queries by the selected store-time range', async ()
=> {
+ const messages = await queryMessages({
+ topic: 'order-create',
+ startTime: Date.parse('2026-07-01T10:24:00.000Z'),
+ endTime: Date.parse('2026-07-01T10:26:00.000Z'),
+ });
+
+ expect(messages.map((message) => message.msgId)).toEqual([
+ 'AC1E0A6400002A9F0000000001A3F7C2',
+ ]);
+ });
+
it('returns copied message trace rows', async () => {
const first = await getMessageTrace('AC1E0A6400002A9F0000000001A3F2B1');
expect(first?.nodes[0].title).toBe('Producer 发送');
diff --git a/web/src/services/messageService.ts
b/web/src/services/messageService.ts
index 44d74c27..80decb21 100644
--- a/web/src/services/messageService.ts
+++ b/web/src/services/messageService.ts
@@ -17,6 +17,13 @@ const cloneTrace = (trace: TraceRecord): TraceRecord => ({
const cloneDLQGroup = (group: DLQGroup): DLQGroup => ({ ...group });
+const toStoreTimestamp = (storeTime: MessageRecord['storeTime']): number => {
+ if (typeof storeTime === 'number') return storeTime;
+
+ const parsed = Date.parse(storeTime);
+ return Number.isNaN(parsed) ? 0 : parsed;
+};
+
export async function queryMessages(params: MessageQuery):
Promise<MessageRecord[]> {
if (isMockMode()) {
let result = [...mockMessages];
@@ -24,6 +31,12 @@ export async function queryMessages(params: MessageQuery):
Promise<MessageRecord
if (params.tag) result = result.filter((m) => m.tag === params.tag);
if (params.key) result = result.filter((m) => m.key.includes(params.key!));
if (params.msgId) result = result.filter((m) => m.msgId === params.msgId);
+ if (params.startTime !== undefined) {
+ result = result.filter((m) => toStoreTimestamp(m.storeTime) >=
params.startTime!);
+ }
+ if (params.endTime !== undefined) {
+ result = result.filter((m) => toStoreTimestamp(m.storeTime) <=
params.endTime!);
+ }
return sortMessagesByStoreTimeDesc((result as unknown as
MessageRecord[]).map(cloneMessage));
}
return messageApi.queryMessages(params);
diff --git a/web/src/services/opsService.test.ts
b/web/src/services/opsService.test.ts
index ae02a2dc..e06f1448 100644
--- a/web/src/services/opsService.test.ts
+++ b/web/src/services/opsService.test.ts
@@ -82,6 +82,23 @@ describe('ops service mock data', () => {
expect(afterUpdate?.channels).toEqual(['webhook']);
});
+ it('rejects updates for unknown alert rule IDs', async () => {
+ const before = await listAlertRules();
+ const missingRule = {
+ ...before[0],
+ id: 'missing-alert-rule',
+ name: 'missing rule',
+ };
+
+ await expect(updateAlertRule(missingRule)).rejects.toThrow(
+ 'Alert rule not found: missing-alert-rule',
+ );
+
+ const after = await listAlertRules();
+ expect(after.map((rule) => rule.id)).toEqual(before.map((rule) =>
rule.id));
+ expect(after.find((rule) => rule.id ===
'missing-alert-rule')).toBeUndefined();
+ });
+
it('returns copied system alert rows', async () => {
const first = await listSystemAlerts();
const originalTitle = first[0].title;
diff --git a/web/src/services/opsService.ts b/web/src/services/opsService.ts
index b27819ad..0bcde5f0 100644
--- a/web/src/services/opsService.ts
+++ b/web/src/services/opsService.ts
@@ -101,8 +101,9 @@ export async function createAlertRule(data:
Partial<AlertRule>): Promise<AlertRu
export async function updateAlertRule(data: AlertRule): Promise<AlertRule> {
if (isMockMode()) {
const index = alertRulesState.findIndex((rule) => rule.id === data.id);
+ if (index < 0) throw new Error(`Alert rule not found: ${data.id}`);
const rule = copyAlertRule(data);
- if (index >= 0) alertRulesState[index] = rule;
+ alertRulesState[index] = rule;
return copyAlertRule(rule);
}
return opsApi.updateAlertRule(data);
diff --git a/web/src/services/topicService.test.ts
b/web/src/services/topicService.test.ts
index 04580f07..4fe13bf8 100644
--- a/web/src/services/topicService.test.ts
+++ b/web/src/services/topicService.test.ts
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { getTopicConsumers, getTopicRoutes, listTopics } from './topicService';
+import { createTopic, getTopicConsumers, getTopicRoutes, listTopics } from
'./topicService';
vi.mock('./dataMode', () => ({ isMockMode: () => true }));
vi.mock('../config', () => ({
@@ -68,4 +68,24 @@ describe('topic service mock data', () => {
expect(blankSearchTopics).toHaveLength(allTopics.length);
});
+
+ it('rejects duplicate topic creates in the same cluster', async () => {
+ const existing = (await listTopics({ search: 'order-create' }))[0];
+ const before = await listTopics({ clusterId: existing.clusterId });
+
+ await expect(
+ createTopic({
+ name: existing.name,
+ clusterId: existing.clusterId,
+ namespace: existing.namespace,
+ type: existing.type,
+ writeQueues: existing.writeQueues,
+ readQueues: existing.readQueues,
+ perm: existing.perm,
+ }),
+ ).rejects.toThrow(`Topic already exists: ${existing.name}`);
+
+ const after = await listTopics({ clusterId: existing.clusterId });
+ expect(after).toEqual(before);
+ });
});
diff --git a/web/src/services/topicService.ts b/web/src/services/topicService.ts
index ca1e945a..90617928 100644
--- a/web/src/services/topicService.ts
+++ b/web/src/services/topicService.ts
@@ -31,6 +31,11 @@ export async function listTopics(params?: TopicQuery):
Promise<Topic[]> {
export async function createTopic(data: Partial<Topic>): Promise<Topic> {
if (isMockMode()) {
+ const duplicate = mockTopics.some(
+ (topic) => topic.name === data.name && topic.clusterId ===
data.clusterId,
+ );
+ if (duplicate) throw new Error(`Topic already exists: ${data.name}`);
+
const topic = {
...data,
createdAt: new Date().toISOString(),
diff --git a/web/src/stores/clusterStore.test.ts
b/web/src/stores/clusterStore.test.ts
index 3084b22c..8463c33f 100644
--- a/web/src/stores/clusterStore.test.ts
+++ b/web/src/stores/clusterStore.test.ts
@@ -52,6 +52,23 @@ const cluster: ClusterInfo = {
tpsHistory: [100, 120],
};
+const newerCluster: ClusterInfo = {
+ ...cluster,
+ id: 'cluster-staging',
+ name: 'rocketmq-staging',
+};
+
+function deferred<T>() {
+ let resolve!: (value: T) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise<T>((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+
+ return { promise, resolve, reject };
+}
+
describe('clusterStore', () => {
afterEach(() => {
vi.mocked(listClusters).mockReset();
@@ -70,6 +87,33 @@ describe('clusterStore', () => {
});
});
+ it('keeps the newest cluster list when overlapping loads finish out of
order', async () => {
+ const firstLoad = deferred<ClusterInfo[]>();
+ const secondLoad = deferred<ClusterInfo[]>();
+ vi.mocked(listClusters)
+ .mockReturnValueOnce(firstLoad.promise)
+ .mockReturnValueOnce(secondLoad.promise);
+
+ const firstFetch = useClusterStore.getState().fetchClusters();
+ const secondFetch = useClusterStore.getState().fetchClusters();
+
+ secondLoad.resolve([newerCluster]);
+ await secondFetch;
+
+ expect(useClusterStore.getState()).toMatchObject({
+ clusters: [newerCluster],
+ loading: false,
+ });
+
+ firstLoad.resolve([cluster]);
+ await firstFetch;
+
+ expect(useClusterStore.getState()).toMatchObject({
+ clusters: [newerCluster],
+ loading: false,
+ });
+ });
+
it('resets loading when loading clusters fails', async () => {
const error = new Error('failed to load clusters');
vi.mocked(listClusters).mockRejectedValue(error);
diff --git a/web/src/stores/clusterStore.ts b/web/src/stores/clusterStore.ts
index 48795048..ea6c349f 100644
--- a/web/src/stores/clusterStore.ts
+++ b/web/src/stores/clusterStore.ts
@@ -25,16 +25,23 @@ interface ClusterState {
fetchClusters: () => Promise<void>;
}
+let latestFetchRequestId = 0;
+
const useClusterStore = create<ClusterState>((set) => ({
clusters: [],
loading: false,
fetchClusters: async () => {
+ const requestId = ++latestFetchRequestId;
set({ loading: true });
try {
const clusters = await listClusters();
- set({ clusters });
+ if (requestId === latestFetchRequestId) {
+ set({ clusters });
+ }
} finally {
- set({ loading: false });
+ if (requestId === latestFetchRequestId) {
+ set({ loading: false });
+ }
}
},
}));