This is an automated email from the ASF dual-hosted git repository.

guoqqqi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-dashboard.git


The following commit(s) were added to refs/heads/master by this push:
     new 5c5a00b51 fix: stop corrupting object-form upstream nodes on 
conversion (#3433)
5c5a00b51 is described below

commit 5c5a00b5135c0838693ae49c951e170c9ed9f78c
Author: Yuhan <[email protected]>
AuthorDate: Mon Jul 20 16:57:35 2026 +0800

    fix: stop corrupting object-form upstream nodes on conversion (#3433)
---
 .../regression/upstreams.object-form-nodes.spec.ts | 92 ++++++++++++++++++++++
 .../form-slice/FormPartUpstream/FormItemNodes.tsx  | 22 ++----
 .../FormPartUpstream/nodes-conversion.test.ts      | 79 +++++++++++++++++++
 .../FormPartUpstream/nodes-conversion.ts           | 62 +++++++++++++++
 src/types/schema/apisix/upstreams.ts               |  5 +-
 5 files changed, 244 insertions(+), 16 deletions(-)

diff --git a/e2e/tests/regression/upstreams.object-form-nodes.spec.ts 
b/e2e/tests/regression/upstreams.object-form-nodes.spec.ts
new file mode 100644
index 000000000..7af2bd5b6
--- /dev/null
+++ b/e2e/tests/regression/upstreams.object-form-nodes.spec.ts
@@ -0,0 +1,92 @@
+/**
+ * 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.
+ */
+
+// Regression for a data-integrity item of apache/apisix-dashboard#3417:
+// object-form nodes (`{"host:port": weight}` — the Admin API's documented
+// shorthand, also valid without a port and with IPv6 keys) were parsed
+// with `key.split(':')`. An IPv6 key `[::1]:1980` displayed as host "["
+// with port 1, and a port-less key got an invented `port: 1`; the table's
+// flush-on-mousedown sync then persisted the corruption on any edit-save
+// (a port-less node resolves its port by scheme at runtime — port 1 does
+// not). Both corrupted shapes were accepted by the Admin API, so a no-op
+// Edit → Save silently destroyed a working upstream.
+
+import { upstreamsPom } from '@e2e/pom/upstreams';
+import { randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiGoto } from '@e2e/utils/ui';
+import { expect } from '@playwright/test';
+
+import { deleteAllUpstreams } from '@/apis/upstreams';
+import type { APISIXType } from '@/types/schema/apisix';
+
+test.beforeAll(async () => {
+  await deleteAllUpstreams(e2eReq);
+});
+
+test.afterAll(async () => {
+  await deleteAllUpstreams(e2eReq);
+});
+
+test('object-form nodes survive display and no-op edit-save', async ({
+  page,
+}) => {
+  const name = randomId('reg-obj-nodes');
+  const res = await e2eReq.put<{ value: APISIXType['Upstream'] }>(
+    `/upstreams/${name}`,
+    {
+      name,
+      type: 'roundrobin',
+      scheme: 'http',
+      nodes: { 'httpbin.org': 1, '[::1]:1980': 2 },
+    }
+  );
+  const id = res.data.value.id;
+
+  await uiGoto(page, '/upstreams/detail/$id', { id });
+  await upstreamsPom.isDetailPage(page);
+
+  // display must not shred the IPv6 host nor invent a port for the
+  // port-less node (unfixed: host "[" / port "1" for both rows)
+  await expect(page.getByRole('cell', { name: '[::1]', exact: true 
})).toBeVisible();
+  await expect(page.getByRole('cell', { name: '1980', exact: true 
})).toBeVisible();
+  const portlessRow = page.getByRole('row', { name: /httpbin\.org/ });
+  await expect(portlessRow).toBeVisible();
+  await expect(portlessRow.getByRole('cell', { name: '1', exact: true 
})).toHaveCount(1); // weight only, no port=1 cell
+
+  await page.getByRole('button', { name: 'Edit' }).click();
+  await page.getByRole('button', { name: 'Save' }).click();
+  await expect(
+    page.getByRole('alert').filter({ hasText: /success/i })
+  ).toBeVisible();
+
+  const after = await e2eReq.get<{ value: APISIXType['Upstream'] }>(
+    `/upstreams/${id}`
+  );
+  const nodes = after.data.value.nodes as APISIXType['UpstreamNode'][];
+  expect(Array.isArray(nodes)).toBe(true);
+  const byHost = Object.fromEntries(nodes.map((n) => [n.host, n]));
+  // the port-less node must stay port-less (scheme decides at runtime)
+  expect(byHost['httpbin.org']).toBeTruthy();
+  expect(byHost['httpbin.org'].port).toBeUndefined();
+  expect(byHost['httpbin.org'].weight).toBe(1);
+  // the IPv6 node must keep its bracketed host and real port
+  expect(byHost['[::1]']).toBeTruthy();
+  expect(byHost['[::1]'].port).toBe(1980);
+  expect(byHost['[::1]'].weight).toBe(2);
+});
diff --git a/src/components/form-slice/FormPartUpstream/FormItemNodes.tsx 
b/src/components/form-slice/FormPartUpstream/FormItemNodes.tsx
index 114e70f02..507171eab 100644
--- a/src/components/form-slice/FormPartUpstream/FormItemNodes.tsx
+++ b/src/components/form-slice/FormPartUpstream/FormItemNodes.tsx
@@ -31,6 +31,7 @@ import { AntdConfigProvider } from 
'@/config/antdConfigProvider';
 import { APISIX, type APISIXType } from '@/types/schema/apisix';
 
 import { genControllerProps } from '../../form/util';
+import { objToUpstreamNodes } from './nodes-conversion';
 
 type DataSource = APISIXType['UpstreamNode'] & APISIXType['ID'];
 
@@ -48,19 +49,6 @@ const zValidateField = <T extends ZodRawShape, R extends 
keyof T>(
   return Promise.reject(new Error(error.message));
 };
 
-const objToUpstreamNodes = (data: APISIXType['UpstreamNodeObj']) => {
-  return Object.entries(data).map(([key, val]) => {
-    const [host, port] = key.split(':');
-    const d: APISIXType['UpstreamNode'] = {
-      host,
-      port: Number(port) || 1,
-      weight: val,
-      priority: 0,
-    };
-    return d;
-  });
-};
-
 const toDataSource = (data: APISIXType['UpstreamNodeListOrObj']): DataSource[] 
=> {
   let val: APISIXType['UpstreamNodes'];
   if (isNil(data)) val = [];
@@ -175,10 +163,12 @@ export const FormItemNodes = <T extends FieldValues>(
       next.push({
         id: existing?.id ?? nanoid(),
         host,
+        // no ?? 80 fallback: a port-less node must stay port-less through
+        // the flush, or a no-op save would invent a port (#3417)
         port:
           portRaw !== undefined && portRaw !== ''
             ? Number(portRaw)
-            : (existing?.port ?? 80),
+            : existing?.port,
         weight:
           weightRaw !== undefined && weightRaw !== ''
             ? Number(weightRaw)
@@ -233,8 +223,10 @@ export const FormItemNodes = <T extends FieldValues>(
         dataIndex: 'port',
         valueType: 'digit',
         formItemProps: genProps('port'),
+        // port is optional (scheme decides at runtime) — same guard as
+        // priority below
         render: (_, entity) => {
-          return entity.port.toString();
+          return entity.port?.toString() || '-';
         },
       },
       {
diff --git 
a/src/components/form-slice/FormPartUpstream/nodes-conversion.test.ts 
b/src/components/form-slice/FormPartUpstream/nodes-conversion.test.ts
new file mode 100644
index 000000000..68c5c9642
--- /dev/null
+++ b/src/components/form-slice/FormPartUpstream/nodes-conversion.test.ts
@@ -0,0 +1,79 @@
+/**
+ * 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 { describe, expect, it } from 'vitest';
+
+import { objToUpstreamNodes } from './nodes-conversion';
+
+// Regression for a data-integrity item of #3417: object-form nodes
+// (`{"host:port": weight}`, the Admin API's documented shorthand) were
+// parsed with `key.split(':')`, which shreds IPv6 hosts, and missing
+// ports were replaced with an invented `port: 1` (`Number(port) || 1`) —
+// a no-op edit-save then persisted the corruption (real traffic impact:
+// a port-less node resolves by scheme at runtime; port 1 does not).
+// Gateway facts (verified live): array-form nodes accept a missing port
+// (201) and require IPv6 hosts bracketed ("[::1]" 201, "::1" 400); the
+// object form accepts both bracketed-with-port ("[::1]:1980") and bare
+// ("::1") IPv6 keys.
+
+describe('objToUpstreamNodes', () => {
+  it('parses host:port keys', () => {
+    expect(objToUpstreamNodes({ 'httpbin.org:8080': 5 })).toEqual([
+      { host: 'httpbin.org', port: 8080, weight: 5 },
+    ]);
+  });
+
+  it('does not invent a port for port-less keys', () => {
+    expect(objToUpstreamNodes({ 'httpbin.org': 1 })).toEqual([
+      { host: 'httpbin.org', weight: 1 },
+    ]);
+  });
+
+  it('keeps bracketed IPv6 hosts intact', () => {
+    expect(objToUpstreamNodes({ '[::1]:1980': 1 })).toEqual([
+      { host: '[::1]', port: 1980, weight: 1 },
+    ]);
+  });
+
+  it('handles bracketed IPv6 without a port', () => {
+    expect(objToUpstreamNodes({ '[2001:db8::1]': 2 })).toEqual([
+      { host: '[2001:db8::1]', weight: 2 },
+    ]);
+  });
+
+  it('brackets bare IPv6 keys (only the bracketed form is storable as an array 
node)', () => {
+    expect(objToUpstreamNodes({ '::1': 1 })).toEqual([
+      { host: '[::1]', weight: 1 },
+    ]);
+  });
+
+  it('treats a non-numeric tail as part of the host instead of corrupting it', 
() => {
+    expect(objToUpstreamNodes({ 'weird:host': 1 })).toEqual([
+      { host: 'weird:host', weight: 1 },
+    ]);
+  });
+
+  it('does not invent priority', () => {
+    const [node] = objToUpstreamNodes({ 'a.local:80': 1 });
+    expect('priority' in node).toBe(false);
+  });
+
+  it('converts IPv4 host:port', () => {
+    expect(objToUpstreamNodes({ '127.0.0.1:1980': 1 })).toEqual([
+      { host: '127.0.0.1', port: 1980, weight: 1 },
+    ]);
+  });
+});
diff --git a/src/components/form-slice/FormPartUpstream/nodes-conversion.ts 
b/src/components/form-slice/FormPartUpstream/nodes-conversion.ts
new file mode 100644
index 000000000..268977175
--- /dev/null
+++ b/src/components/form-slice/FormPartUpstream/nodes-conversion.ts
@@ -0,0 +1,62 @@
+/**
+ * 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 type { APISIXType } from '@/types/schema/apisix';
+
+const isValidPort = (n: number) => Number.isInteger(n) && n >= 1 && n <= 65535;
+
+/**
+ * Parse an object-form node key into host + optional port.
+ *
+ * Gateway contract (verified against a live Admin API): keys may be
+ * "host", "host:port", "[v6]", "[v6]:port" or a bare IPv6 like "::1";
+ * array-form nodes accept a MISSING port (the scheme decides at runtime)
+ * but reject an unbracketed IPv6 host — so a bare IPv6 key must come out
+ * bracketed, and a missing port must stay missing (never invented).
+ */
+export const parseNodeKey = (key: string): { host: string; port?: number } => {
+  if (key.startsWith('[')) {
+    const end = key.indexOf(']');
+    if (end !== -1) {
+      const host = key.slice(0, end + 1);
+      const rest = key.slice(end + 1);
+      if (rest.startsWith(':')) {
+        const port = Number(rest.slice(1));
+        if (isValidPort(port)) return { host, port };
+      }
+      return { host };
+    }
+  }
+  const first = key.indexOf(':');
+  const last = key.lastIndexOf(':');
+  // two or more colons without brackets: a bare IPv6 key
+  if (first !== -1 && first !== last) return { host: `[${key}]` };
+  if (last !== -1) {
+    const port = Number(key.slice(last + 1));
+    if (isValidPort(port)) return { host: key.slice(0, last), port };
+  }
+  return { host: key };
+};
+
+export const objToUpstreamNodes = (data: APISIXType['UpstreamNodeObj']) => {
+  return Object.entries(data).map(([key, val]) => {
+    const d: APISIXType['UpstreamNode'] = {
+      ...parseNodeKey(key),
+      weight: val,
+    };
+    return d;
+  });
+};
diff --git a/src/types/schema/apisix/upstreams.ts 
b/src/types/schema/apisix/upstreams.ts
index 232c3ca3b..764f4fdfb 100644
--- a/src/types/schema/apisix/upstreams.ts
+++ b/src/types/schema/apisix/upstreams.ts
@@ -58,7 +58,10 @@ const UpstreamPassHost = z.union([
 
 const UpstreamNode = z.object({
   host: z.string().min(1),
-  port: z.number().int().gte(1).lte(65535),
+  // the Admin API accepts array-form nodes without a port (the scheme
+  // decides at runtime) — requiring it here rejected valid API-created
+  // nodes and forced the object-form converter to invent one (#3417)
+  port: z.number().int().gte(1).lte(65535).optional(),
   weight: z.number().int(),
   priority: z.number().int().optional(),
 });

Reply via email to