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

LiteSun 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 266fb7eb7 test: add round-trip invariant and update/delete 
mutation-failure coverage (#3448)
266fb7eb7 is described below

commit 266fb7eb7b23908500ee60e87ab19d537767ee9f
Author: Yuhan <[email protected]>
AuthorDate: Tue Jul 28 14:27:52 2026 +0800

    test: add round-trip invariant and update/delete mutation-failure coverage 
(#3448)
---
 .../regression/form.mutation-failure-error.spec.ts |  72 +++++++
 .../regression/form.round-trip-invariant.spec.ts   | 223 +++++++++++++++++++++
 2 files changed, 295 insertions(+)

diff --git a/e2e/tests/regression/form.mutation-failure-error.spec.ts 
b/e2e/tests/regression/form.mutation-failure-error.spec.ts
index 80e195f0e..47cca742e 100644
--- a/e2e/tests/regression/form.mutation-failure-error.spec.ts
+++ b/e2e/tests/regression/form.mutation-failure-error.spec.ts
@@ -29,6 +29,7 @@ import { routesPom } from '@e2e/pom/routes';
 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 { uiFillUpstreamRequiredFields } from '@e2e/utils/ui/upstreams';
 import { expect } from '@playwright/test';
 
@@ -96,3 +97,74 @@ test('route create surface a visible error when the Admin 
API replies 500', asyn
     .filter({ hasText: /forced 500|fail|error/i });
   await expect(errorToast.first()).toBeVisible({ timeout: 10000 });
 });
+
+const seedRoute = async (id: string) => {
+  await e2eReq.put(`/routes/${id}`, {
+    name: id,
+    uri: `/regression/${id}`,
+    upstream: { type: 'roundrobin', nodes: { 'mut-fail.local:80': 1 } },
+  });
+};
+
+test('route edit-save surfaces a visible error when the Admin API replies 
500', async ({
+  page,
+}) => {
+  // The create path (POST) is covered above; update (PUT) goes through the
+  // same interceptor but is a distinct verb worth pinning.
+  const id = randomId('reg-mut-edit');
+  await seedRoute(id);
+
+  await page.route('**/apisix/admin/routes/*', async (route) => {
+    if (route.request().method() === 'PUT') {
+      await route.fulfill({
+        status: 500,
+        contentType: 'application/json',
+        body: JSON.stringify({ error_msg: 'forced 500 on update' }),
+      });
+    } else {
+      await route.fallback();
+    }
+  });
+
+  await uiGoto(page, '/routes/detail/$id', { id });
+  await routesPom.isDetailPage(page);
+
+  // A no-op edit still issues the PUT — enough to exercise the failure path.
+  await page.getByRole('button', { name: 'Edit' }).click();
+  await page.getByRole('button', { name: 'Save', exact: true }).click();
+
+  const errorToast = page
+    .getByRole('alert')
+    .filter({ hasText: /forced 500|fail|error/i });
+  await expect(errorToast.first()).toBeVisible({ timeout: 10000 });
+});
+
+test('route delete surfaces a visible error when the Admin API replies 500', 
async ({
+  page,
+}) => {
+  const id = randomId('reg-mut-del');
+  await seedRoute(id);
+
+  await page.route('**/apisix/admin/routes/*', async (route) => {
+    if (route.request().method() === 'DELETE') {
+      await route.fulfill({
+        status: 500,
+        contentType: 'application/json',
+        body: JSON.stringify({ error_msg: 'forced 500 on delete' }),
+      });
+    } else {
+      await route.fallback();
+    }
+  });
+
+  await uiGoto(page, '/routes/detail/$id', { id });
+  await routesPom.isDetailPage(page);
+
+  await page.getByRole('button', { name: 'Delete' }).click();
+  await page.getByRole('dialog').getByRole('button', { name: 'Delete' 
}).click();
+
+  const errorToast = page
+    .getByRole('alert')
+    .filter({ hasText: /forced 500|fail|error/i });
+  await expect(errorToast.first()).toBeVisible({ timeout: 10000 });
+});
diff --git a/e2e/tests/regression/form.round-trip-invariant.spec.ts 
b/e2e/tests/regression/form.round-trip-invariant.spec.ts
new file mode 100644
index 000000000..2d133c329
--- /dev/null
+++ b/e2e/tests/regression/form.round-trip-invariant.spec.ts
@@ -0,0 +1,223 @@
+/**
+ * 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 the #3417 test-suite gap: there was no general round-trip
+// invariant. #3414 (the SSL mTLS client block wiped on edit-save) is one
+// instance of a whole class — a resource seeded via the Admin API, opened in
+// the dashboard and saved WITHOUT any user change, must come back unchanged.
+// Per-field specs cover specific known losses; this is the parametrised guard
+// that catches future ones.
+//
+// Both sides of the comparison are Admin-API-stored values, so they are
+// canonicalised by the gateway. `canonicalize` drops the server-managed
+// timestamps and folds any array-form upstream `nodes` back to the object-map
+// form the gateway stores, so a benign representation reshuffle isn't a false
+// failure, while a genuinely dropped or emptied field still surfaces as a 
diff.
+//
+// SSL and Secret are intentionally excluded: their private key / manager
+// tokens are write-only (never returned by the API), so a pure no-op save
+// legitimately cannot reproduce them. The SSL client-block case is covered by
+// ssls.noop-edit-preserves-client.spec.ts, which re-enters the key.
+
+import { randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiGoto, uiHasToastMsg } from '@e2e/utils/ui';
+import { expect, type Page } from '@playwright/test';
+
+import type { FileRouteTypes } from '@/routeTree.gen';
+
+const canonicalize = (value: unknown): unknown => {
+  if (Array.isArray(value)) return value.map(canonicalize);
+  if (value && typeof value === 'object') {
+    const out: Record<string, unknown> = {};
+    for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
+      if (k === 'create_time' || k === 'update_time') continue;
+      if (k === 'nodes' && Array.isArray(v)) {
+        out.nodes = Object.fromEntries(
+          (v as { host: string; port?: number; weight: number }[]).map((n) => [
+            n.port === undefined ? n.host : `${n.host}:${n.port}`,
+            n.weight,
+          ])
+        );
+      } else {
+        out[k] = canonicalize(v);
+      }
+    }
+    return out;
+  }
+  return value;
+};
+
+type RoundTripCase = {
+  label: string;
+  // PUT a rich resource, returning its id and the stored value (S1).
+  seed: () => Promise<{ id: string; stored: Record<string, unknown> }>;
+  detailTo: FileRouteTypes['to'];
+  params: (id: string) => Record<string, string>;
+  // Re-read the stored value after the no-op save (S2).
+  read: (id: string) => Promise<Record<string, unknown>>;
+  cleanup: (id: string) => Promise<void>;
+};
+
+const put = async (path: string, body: object) => {
+  const res = await e2eReq.put<{ value: Record<string, unknown> }>(path, body);
+  return res.data.value;
+};
+const get = async (path: string) => {
+  const res = await e2eReq.get<{ value: Record<string, unknown> }>(path);
+  return res.data.value;
+};
+const del = (path: string) => e2eReq.delete(path).catch(() => undefined);
+
+const cases: RoundTripCase[] = [
+  {
+    label: 'route',
+    seed: async () => {
+      const id = randomId('rt-route');
+      const stored = await put(`/routes/${id}`, {
+        name: id,
+        desc: 'round-trip route',
+        uri: `/rt/${id}`,
+        labels: { env: 'prod', team: 'gateway' },
+        methods: ['GET', 'POST'],
+        plugins: { prometheus: {} },
+        upstream: {
+          type: 'roundrobin',
+          nodes: { 'rt1.local:80': 1, 'rt2.local:81': 2 },
+          timeout: { connect: 6, send: 6, read: 6 },
+        },
+      });
+      return { id, stored };
+    },
+    detailTo: '/routes/detail/$id',
+    params: (id) => ({ id }),
+    read: (id) => get(`/routes/${id}`),
+    cleanup: (id) => del(`/routes/${id}`),
+  },
+  {
+    label: 'service',
+    seed: async () => {
+      const id = randomId('rt-service');
+      const stored = await put(`/services/${id}`, {
+        name: id,
+        desc: 'round-trip service',
+        labels: { team: 'x' },
+        hosts: ['rt-svc.example.com'],
+        enable_websocket: true,
+        plugins: { prometheus: {} },
+        upstream: {
+          type: 'roundrobin',
+          nodes: { 'rt-svc.local:80': 1 },
+        },
+      });
+      return { id, stored };
+    },
+    detailTo: '/services/detail/$id',
+    params: (id) => ({ id }),
+    read: (id) => get(`/services/${id}`),
+    cleanup: (id) => del(`/services/${id}`),
+  },
+  {
+    label: 'upstream',
+    seed: async () => {
+      const id = randomId('rt-upstream');
+      const stored = await put(`/upstreams/${id}`, {
+        name: id,
+        desc: 'round-trip upstream',
+        labels: { tier: 'gold' },
+        type: 'roundrobin',
+        nodes: { 'rt-up1.local:80': 1, 'rt-up2.local:80': 2 },
+        timeout: { connect: 6, send: 6, read: 6 },
+        checks: {
+          active: {
+            type: 'http',
+            http_path: '/health',
+            healthy: { interval: 2, successes: 2 },
+            unhealthy: { interval: 1, http_failures: 3 },
+          },
+        },
+      });
+      return { id, stored };
+    },
+    detailTo: '/upstreams/detail/$id',
+    params: (id) => ({ id }),
+    read: (id) => get(`/upstreams/${id}`),
+    cleanup: (id) => del(`/upstreams/${id}`),
+  },
+  {
+    label: 'consumer',
+    // No auth plugin here on purpose: key-auth's `key` is a sensitive field
+    // the form does not round-trip (a no-op save re-generates it), so it is
+    // excluded for the same reason as SSL/Secret. This case still guards the
+    // username/desc/labels round-trip. (The key-auth regeneration on an
+    // unrelated edit is noted separately for investigation.)
+    seed: async () => {
+      const username = randomId('rt_consumer').replace(/-/g, '_');
+      const stored = await put('/consumers', {
+        username,
+        desc: 'round-trip consumer',
+        labels: { app: 'y' },
+      });
+      return { id: username, stored };
+    },
+    detailTo: '/consumers/detail/$username',
+    params: (username) => ({ username }),
+    read: (username) => get(`/consumers/${username}`),
+    cleanup: (username) => del(`/consumers/${username}`),
+  },
+  {
+    label: 'consumer_group',
+    seed: async () => {
+      const id = randomId('rt-cg');
+      const stored = await put(`/consumer_groups/${id}`, {
+        desc: 'round-trip consumer group',
+        labels: { grp: 'z' },
+        plugins: { prometheus: {} },
+      });
+      return { id, stored };
+    },
+    detailTo: '/consumer_groups/detail/$id',
+    params: (id) => ({ id }),
+    read: (id) => get(`/consumer_groups/${id}`),
+    cleanup: (id) => del(`/consumer_groups/${id}`),
+  },
+];
+
+const noopEditSave = async (page: Page) => {
+  await page.getByRole('button', { name: 'Edit' }).click();
+  await page.getByRole('button', { name: 'Save', exact: true }).click();
+  await uiHasToastMsg(page, { hasText: /success/i });
+};
+
+for (const c of cases) {
+  test(`no-op edit-save preserves a ${c.label} unchanged`, async ({ page }) => 
{
+    const { id, stored: before } = await c.seed();
+    try {
+      await uiGoto(page, c.detailTo, c.params(id));
+      await noopEditSave(page);
+
+      const after = await c.read(id);
+      expect(
+        canonicalize(after),
+        `${c.label} changed after a no-op edit-save`
+      ).toEqual(canonicalize(before));
+    } finally {
+      await c.cleanup(id);
+    }
+  });
+}

Reply via email to