From 41954224e270a8f1f5816d0650fa56b175b979bb Mon Sep 17 00:00:00 2001 From: Ming Wen Date: Tue, 21 Jul 2026 17:07:17 +0800 Subject: [PATCH 1/3] test(e2e): migrate the seed-tail tests off the Admin API onto etcd/status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 2 of retiring the Admin API (after the admin.enabled switch, #791): move the e2e tests that used the Admin API only for setup or readiness — not to test the Admin API itself — onto the direct-etcd SeedClient and the metrics/status listener, and run them with admin: false. Seed-only (4): cache-ttl (admin:false only); datadog (AdminClient → SeedClient for the exporter + routing seed); guardrail-disabled-bypass (Gate-A readiness moves from GET /admin/v1/guardrails to GET /status/config applied resource_counts — stronger, reads the applied snapshot not just the etcd write); provider-key-rotation (the revision oracle becomes an etcd read-back asserting the rotated secret — a more direct no-op guard, no shared-harness change). Mixed (5): background-health, runtime-mixed-filtering, cooldown-contract (7 spawn sites), retry-on-429-vs-background-ignore, runtime-status — seeding was already on SeedClient; the only surviving admin-shaped call is listModelStatuses, which reads GET /status/models on the metrics listener and survives admin:false. Tests that exercise the Admin API surface itself (characterization, file-mode 409, status-equivalence, health, rotate, deprecation-header, admin-auth) stay admin-on until the Admin API is removed. No harness or Rust changes; assertions unchanged (two readiness/no-op guards strengthened). --- .../src/cases/background-health-e2e.test.ts | 5 ++- .../src/cases/cache-ttl-eviction-e2e.test.ts | 3 +- .../src/cases/cooldown-contract-e2e.test.ts | 28 +++++++++--- .../src/cases/datadog-exporter-e2e.test.ts | 31 +++++++------ .../guardrail-disabled-bypass-e2e.test.ts | 44 ++++++++----------- .../cases/provider-key-rotation-e2e.test.ts | 28 ++++++------ ...ry-on-429-vs-background-ignore-e2e.test.ts | 5 ++- .../cases/runtime-mixed-filtering-e2e.test.ts | 5 ++- .../e2e/src/cases/runtime-status-e2e.test.ts | 5 ++- 9 files changed, 88 insertions(+), 66 deletions(-) diff --git a/tests/e2e/src/cases/background-health-e2e.test.ts b/tests/e2e/src/cases/background-health-e2e.test.ts index 6940ab06..5584b179 100644 --- a/tests/e2e/src/cases/background-health-e2e.test.ts +++ b/tests/e2e/src/cases/background-health-e2e.test.ts @@ -59,7 +59,10 @@ describe("background health e2e", () => { }, }); - app = await spawnApp(); + // The admin listener is off; `admin` here is used only for + // listModelStatuses, which reads GET /status/models on the metrics + // listener. Resources are seeded straight to etcd via `seed`. + app = await spawnApp({ admin: false }); admin = new AdminClient(app.adminUrl, app.adminKey, app.metricsUrl); seed = new SeedClient(etcd, app.etcdPrefix); diff --git a/tests/e2e/src/cases/cache-ttl-eviction-e2e.test.ts b/tests/e2e/src/cases/cache-ttl-eviction-e2e.test.ts index cdec82c1..aad24a51 100644 --- a/tests/e2e/src/cases/cache-ttl-eviction-e2e.test.ts +++ b/tests/e2e/src/cases/cache-ttl-eviction-e2e.test.ts @@ -57,7 +57,8 @@ describe("cache TTL eviction e2e: entry expires after ttl_seconds", () => { if (!etcdReachable) return; upstream = await startOpenAiUpstream(); - app = await spawnApp(); + // No admin listener: every resource here is seeded straight to etcd. + app = await spawnApp({ admin: false }); seed = new SeedClient(etcd, app.etcdPrefix); const pk = await seed.createProviderKey({ diff --git a/tests/e2e/src/cases/cooldown-contract-e2e.test.ts b/tests/e2e/src/cases/cooldown-contract-e2e.test.ts index 101c5dba..af1aa7c9 100644 --- a/tests/e2e/src/cases/cooldown-contract-e2e.test.ts +++ b/tests/e2e/src/cases/cooldown-contract-e2e.test.ts @@ -74,7 +74,9 @@ describe("cooldown contract (H1) — 401 cools down despite being non-retryable" }, }); - app = await spawnApp(); + // Admin listener off; `admin` reads only /status/models on the metrics + // listener, and every resource is seeded to etcd via `seed`. + app = await spawnApp({ admin: false }); admin = new AdminClient(app.adminUrl, app.adminKey, app.metricsUrl); seed = new SeedClient(etcd, app.etcdPrefix); @@ -225,7 +227,9 @@ describe("cooldown contract (M1) — 429 cools down even when retry_on_429=false }, }); - app = await spawnApp(); + // Admin listener off; `admin` reads only /status/models on the metrics + // listener, and every resource is seeded to etcd via `seed`. + app = await spawnApp({ admin: false }); admin = new AdminClient(app.adminUrl, app.adminKey, app.metricsUrl); seed = new SeedClient(etcd, app.etcdPrefix); @@ -378,7 +382,9 @@ describe("cooldown contract (H2) — Retry-After header from upstream drives TTL }, }); - app = await spawnApp(); + // Admin listener off; `admin` reads only /status/models on the metrics + // listener, and every resource is seeded to etcd via `seed`. + app = await spawnApp({ admin: false }); admin = new AdminClient(app.adminUrl, app.adminKey, app.metricsUrl); seed = new SeedClient(etcd, app.etcdPrefix); @@ -516,7 +522,9 @@ describe("filter contract (H3) — all candidates unhealthy returns 503", () => errorBody: { error: { message: "all-down B", type: "server_error" } }, }); - app = await spawnApp(); + // Admin listener off; `admin` reads only /status/models on the metrics + // listener, and every resource is seeded to etcd via `seed`. + app = await spawnApp({ admin: false }); admin = new AdminClient(app.adminUrl, app.adminKey, app.metricsUrl); seed = new SeedClient(etcd, app.etcdPrefix); @@ -646,7 +654,9 @@ describe("filter contract (H3 escape hatch) — try_anyway sends to known-bad", errorBody: { error: { message: "still down", type: "server_error" } }, }); - app = await spawnApp(); + // Admin listener off; `admin` reads only /status/models on the metrics + // listener, and every resource is seeded to etcd via `seed`. + app = await spawnApp({ admin: false }); admin = new AdminClient(app.adminUrl, app.adminKey, app.metricsUrl); seed = new SeedClient(etcd, app.etcdPrefix); @@ -765,7 +775,9 @@ describe("cooldown observability — a cooldown transition emits aisix_deploymen }, }); - app = await spawnApp(); + // Admin listener off; `admin` reads only /status/models on the metrics + // listener, and every resource is seeded to etcd via `seed`. + app = await spawnApp({ admin: false }); admin = new AdminClient(app.adminUrl, app.adminKey, app.metricsUrl); seed = new SeedClient(etcd, app.etcdPrefix); @@ -958,7 +970,9 @@ describe("cooldown observability — the state gauge follows a target back into }, }); - app = await spawnApp(); + // Admin listener off; `admin` reads only /status/models on the metrics + // listener, and every resource is seeded to etcd via `seed`. + app = await spawnApp({ admin: false }); admin = new AdminClient(app.adminUrl, app.adminKey, app.metricsUrl); seed = new SeedClient(etcd, app.etcdPrefix); diff --git a/tests/e2e/src/cases/datadog-exporter-e2e.test.ts b/tests/e2e/src/cases/datadog-exporter-e2e.test.ts index 16647d4a..bacac95c 100644 --- a/tests/e2e/src/cases/datadog-exporter-e2e.test.ts +++ b/tests/e2e/src/cases/datadog-exporter-e2e.test.ts @@ -3,8 +3,8 @@ import { createServer, type IncomingMessage, type Server } from "node:http"; import { gunzipSync } from "node:zlib"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { - AdminClient, EtcdClient, + SeedClient, pickFreePort, spawnApp, startOpenAiUpstream, @@ -126,19 +126,19 @@ async function startMockDatadog(): Promise { }; } -async function seedRouting(admin: AdminClient, upstream: OpenAiUpstream, model: string) { - const pk = await admin.createProviderKey({ +async function seedRouting(seed: SeedClient, upstream: OpenAiUpstream, model: string) { + const pk = await seed.createProviderKey({ display_name: `${model}-pk`, secret: PROVIDER_SECRET, api_base: `${upstream.baseUrl}/v1`, }); - await admin.createModel({ + await seed.createModel({ display_name: model, provider: "openai", model_name: "gpt-4o-mini", provider_key_id: pk.id, }); - await admin.createApiKey({ + await seed.createApiKey({ key_hash: CALLER_KEY_HASH, allowed_models: [model], }); @@ -184,12 +184,14 @@ function asRecord(log: unknown): Record { describe("datadog exporter e2e (#688): DP delivers a gzip JSON intake to Datadog", () => { let etcdReachable = false; + let etcd: EtcdClient | undefined; let upstream: OpenAiUpstream | undefined; let dd: MockDatadog | undefined; const apps: SpawnedApp[] = []; beforeAll(async () => { - etcdReachable = await new EtcdClient().ping(); + etcd = new EtcdClient(); + etcdReachable = await etcd.ping(); if (!etcdReachable) return; // Plant the response token in the mock upstream's assistant content so the // content-capture test can search for it in the `full` log body. @@ -226,15 +228,15 @@ describe("datadog exporter e2e (#688): DP delivers a gzip JSON intake to Datadog return; } const app = await spawnApp({ + admin: false, // The API key rides the DP's own env, never the kine config. extraEnv: { [`DD_CRED_${CREDENTIAL_REF.toUpperCase()}_API_KEY`]: DD_API_KEY, }, }); apps.push(app); - // Deliberately seeds via the Admin API: deprecation-window coverage. - const admin = new AdminClient(app.adminUrl, app.adminKey); - await admin.createObservabilityExporter({ + const seed = new SeedClient(etcd!, app.etcdPrefix); + await seed.createObservabilityExporter({ name: "mock-datadog", enabled: true, kind: "datadog", @@ -245,7 +247,7 @@ describe("datadog exporter e2e (#688): DP delivers a gzip JSON intake to Datadog // Default privacy posture: operational metadata only, never content. content_mode: "metadata_only", }); - await seedRouting(admin, upstream, "datadog-exporter-model"); + await seedRouting(seed, upstream, "datadog-exporter-model"); await waitConfigPropagation(async () => { try { @@ -315,15 +317,16 @@ describe("datadog exporter e2e (#688): DP delivers a gzip JSON intake to Datadog const ddFull = await startMockDatadog(); const ddMeta = await startMockDatadog(); const app = await spawnApp({ + admin: false, extraEnv: { [`DD_CRED_${CREDENTIAL_REF.toUpperCase()}_API_KEY`]: DD_API_KEY, }, }); apps.push(app); try { - const admin = new AdminClient(app.adminUrl, app.adminKey); + const seed = new SeedClient(etcd!, app.etcdPrefix); // Two exporters on the same DP: one captures content, one does not. - await admin.createObservabilityExporter({ + await seed.createObservabilityExporter({ name: "datadog-full", enabled: true, kind: "datadog", @@ -332,7 +335,7 @@ describe("datadog exporter e2e (#688): DP delivers a gzip JSON intake to Datadog service: DD_SERVICE, content_mode: "full", }); - await admin.createObservabilityExporter({ + await seed.createObservabilityExporter({ name: "datadog-meta", enabled: true, kind: "datadog", @@ -341,7 +344,7 @@ describe("datadog exporter e2e (#688): DP delivers a gzip JSON intake to Datadog service: DD_SERVICE, content_mode: "metadata_only", }); - await seedRouting(admin, upstream, "datadog-content-model"); + await seedRouting(seed, upstream, "datadog-content-model"); await waitConfigPropagation(async () => { try { diff --git a/tests/e2e/src/cases/guardrail-disabled-bypass-e2e.test.ts b/tests/e2e/src/cases/guardrail-disabled-bypass-e2e.test.ts index 9a7da138..afaf0036 100644 --- a/tests/e2e/src/cases/guardrail-disabled-bypass-e2e.test.ts +++ b/tests/e2e/src/cases/guardrail-disabled-bypass-e2e.test.ts @@ -2,7 +2,6 @@ import { createHash } from "node:crypto"; import OpenAI, { APIError } from "openai"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { - AdminClient, EtcdClient, SeedClient, spawnApp, @@ -49,7 +48,6 @@ const FORBIDDEN_WORD = "supersecret"; describe("guardrail disabled-bypass e2e: enabled:false → no block", () => { let app: SpawnedApp | undefined; let upstream: OpenAiUpstream | undefined; - let admin: AdminClient | undefined; let seed: SeedClient | undefined; let etcdReachable = false; @@ -59,8 +57,9 @@ describe("guardrail disabled-bypass e2e: enabled:false → no block", () => { if (!etcdReachable) return; upstream = await startOpenAiUpstream(); - app = await spawnApp(); - admin = new AdminClient(app.adminUrl, app.adminKey); + // No admin listener: resources are seeded to etcd and load-state is + // read from the metrics/status listener. + app = await spawnApp({ admin: false }); seed = new SeedClient(etcd, app.etcdPrefix); const pk = await seed.createProviderKey({ @@ -113,33 +112,28 @@ describe("guardrail disabled-bypass e2e: enabled:false → no block", () => { // Readiness probe — two gates so the test cannot pass // vacuously. // - // Gate A: confirm the Guardrail row IS in the snapshot. Without - // this, the "forbidden literal arrives at upstream" assertion - // below would also pass if the rule simply hadn't propagated - // yet — indistinguishable from a real `enabled:false` bypass. - // Reading admin /v1/guardrails via the typed JSON helper is - // the cheapest way to verify the resource exists in the store - // the snapshot is built from. + // Gate A: confirm the Guardrail row IS in the applied snapshot. + // Without this, the "forbidden literal arrives at upstream" + // assertion below would also pass if the rule simply hadn't + // propagated yet — indistinguishable from a real `enabled:false` + // bypass. `/status/config`'s `resource_counts` reflects what the + // DP has LOADED (not merely what was written to etcd), served on + // the metrics listener — the admin-off equivalent of the old + // admin `/v1/guardrails` read. // // Gate B: confirm Model + ApiKey + ProviderKey are loaded by // driving a benign chat completion through the proxy. A 200 // response means the dispatcher is ready. await waitConfigPropagation(async () => { try { - const list = (await admin!.json( - "GET", - "/admin/v1/guardrails", - )) as unknown as Array>; - const hasRule = list.some((entry) => { - // Admin list endpoints variably return bare values or - // {value: ...} wrappers; handle either shape generically. - const inner = (entry?.value ?? entry) as Record< - string, - unknown - >; - return inner?.name === "gr-disabled-keyword"; - }); - if (!hasRule) return false; + const cfg = (await ( + await fetch(`${app!.metricsUrl}/status/config`) + ).json()) as { + applied?: { resource_counts?: Record }; + }; + if ((cfg.applied?.resource_counts?.guardrails ?? 0) < 1) { + return false; + } await client.chat.completions.create({ model: "gr-disabled-model", messages: [{ role: "user", content: "ready-probe" }], diff --git a/tests/e2e/src/cases/provider-key-rotation-e2e.test.ts b/tests/e2e/src/cases/provider-key-rotation-e2e.test.ts index c4882c32..1fa7317f 100644 --- a/tests/e2e/src/cases/provider-key-rotation-e2e.test.ts +++ b/tests/e2e/src/cases/provider-key-rotation-e2e.test.ts @@ -2,7 +2,6 @@ import { createHash } from "node:crypto"; import OpenAI from "openai"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { - AdminClient, EtcdClient, SeedClient, spawnApp, @@ -66,13 +65,13 @@ function errMsg(e: unknown): string { describe("provider_key rotation: zero in-flight disruption (#196 L3 / #271)", () => { let app: SpawnedApp | undefined; let upstream: OpenAiUpstream | undefined; - let admin: AdminClient | undefined; + let etcd: EtcdClient | undefined; let seed: SeedClient | undefined; let pkId = ""; let etcdReachable = false; beforeAll(async () => { - const etcd = new EtcdClient(); + etcd = new EtcdClient(); etcdReachable = await etcd.ping(); if (!etcdReachable) return; @@ -89,8 +88,9 @@ describe("provider_key rotation: zero in-flight disruption (#196 L3 / #271)", () }, }); - app = await spawnApp(); - admin = new AdminClient(app.adminUrl, app.adminKey); + // No admin listener: the provider key is seeded and rotated straight + // in etcd, and the rotation is verified by reading the key back. + app = await spawnApp({ admin: false }); seed = new SeedClient(etcd, app.etcdPrefix); const pk = await seed.createProviderKey({ @@ -117,7 +117,7 @@ describe("provider_key rotation: zero in-flight disruption (#196 L3 / #271)", () }); test("an in-place secret rotation under sustained load keeps dispatch serving and bumps revision", async (ctx) => { - if (!etcdReachable || !app || !upstream || !admin || !pkId) { + if (!etcdReachable || !app || !upstream || !etcd || !pkId) { ctx.skip(); return; } @@ -144,10 +144,6 @@ describe("provider_key rotation: zero in-flight disruption (#196 L3 / #271)", () } }); - const revBefore = Number( - ((await admin.json("GET", `/admin/v1/provider_keys/${pkId}`)) as { revision?: number }).revision ?? 0, - ); - // Sustained concurrent load. One worker fires the in-place secret // rotation when it grabs index ROTATE_AT; the rest keep chatting, // so several requests are in flight across the snapshot swap. @@ -199,10 +195,12 @@ describe("provider_key rotation: zero in-flight disruption (#196 L3 / #271)", () ).toEqual([]); expect(success).toBe(TOTAL_REQUESTS); - // The rotation actually took effect (revision bumped). - const revAfter = Number( - ((await admin.json("GET", `/admin/v1/provider_keys/${pkId}`)) as { revision?: number }).revision ?? 0, - ); - expect(revAfter).toBeGreaterThan(revBefore); + // The rotation actually took effect: the store now holds the rotated + // secret (guards the liveness assertion above against a no-op update — + // a rotation that silently did nothing would leave the original secret). + const rawAfter = await etcd.get(`${app.etcdPrefix}/provider_keys/${pkId}`); + expect(rawAfter, "provider_key missing from etcd after rotation").toBeDefined(); + const pkAfter = JSON.parse(rawAfter!) as { secret?: string }; + expect(pkAfter.secret).toBe("sk-mock-v2-rotated"); }, 90_000); }); diff --git a/tests/e2e/src/cases/retry-on-429-vs-background-ignore-e2e.test.ts b/tests/e2e/src/cases/retry-on-429-vs-background-ignore-e2e.test.ts index 922d9322..77272ecb 100644 --- a/tests/e2e/src/cases/retry-on-429-vs-background-ignore-e2e.test.ts +++ b/tests/e2e/src/cases/retry-on-429-vs-background-ignore-e2e.test.ts @@ -63,7 +63,10 @@ describe("retry_on_429 vs background ignore e2e", () => { }, }); - app = await spawnApp(); + // The admin listener is off; `admin` here is used only for + // listModelStatuses, which reads GET /status/models on the metrics + // listener. Resources are seeded straight to etcd via `seed`. + app = await spawnApp({ admin: false }); admin = new AdminClient(app.adminUrl, app.adminKey, app.metricsUrl); seed = new SeedClient(etcd, app.etcdPrefix); diff --git a/tests/e2e/src/cases/runtime-mixed-filtering-e2e.test.ts b/tests/e2e/src/cases/runtime-mixed-filtering-e2e.test.ts index 5d3b5d87..36564f6c 100644 --- a/tests/e2e/src/cases/runtime-mixed-filtering-e2e.test.ts +++ b/tests/e2e/src/cases/runtime-mixed-filtering-e2e.test.ts @@ -76,7 +76,10 @@ describe("runtime mixed filtering e2e", () => { }, }); - app = await spawnApp(); + // The admin listener is off; `admin` here is used only for + // listModelStatuses, which reads GET /status/models on the metrics + // listener. Resources are seeded straight to etcd via `seed`. + app = await spawnApp({ admin: false }); admin = new AdminClient(app.adminUrl, app.adminKey, app.metricsUrl); seed = new SeedClient(etcd, app.etcdPrefix); diff --git a/tests/e2e/src/cases/runtime-status-e2e.test.ts b/tests/e2e/src/cases/runtime-status-e2e.test.ts index 94ee0e95..abbc058b 100644 --- a/tests/e2e/src/cases/runtime-status-e2e.test.ts +++ b/tests/e2e/src/cases/runtime-status-e2e.test.ts @@ -74,7 +74,10 @@ describe("runtime status e2e", () => { }, }); - app = await spawnApp(); + // The admin listener is off; `admin` here is used only for + // listModelStatuses, which reads GET /status/models on the metrics + // listener. Resources are seeded straight to etcd via `seed`. + app = await spawnApp({ admin: false }); admin = new AdminClient(app.adminUrl, app.adminKey, app.metricsUrl); seed = new SeedClient(etcd, app.etcdPrefix); From d102574f3f77d0f0d50e2c240fc67393f059a1da Mon Sep 17 00:00:00 2001 From: Ming Wen Date: Tue, 21 Jul 2026 17:23:25 +0800 Subject: [PATCH 2/3] test(e2e): refresh provider-key-rotation comments to the declarative rotation Audit LOW: the header/scope/reference comments still described the rotation as `PUT /admin/v1/provider_keys/:id` with a revision-bump check, but the test now rotates via a declarative etcd update and verifies the rotated secret by reading the key back. Comment-only. --- .../src/cases/provider-key-rotation-e2e.test.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/e2e/src/cases/provider-key-rotation-e2e.test.ts b/tests/e2e/src/cases/provider-key-rotation-e2e.test.ts index 1fa7317f..83df4aa1 100644 --- a/tests/e2e/src/cases/provider-key-rotation-e2e.test.ts +++ b/tests/e2e/src/cases/provider-key-rotation-e2e.test.ts @@ -20,20 +20,21 @@ import { // and must not wedge dispatch. // // What this pins: under sustained concurrency (8 workers, 80 requests), -// an in-place secret rotation (PUT /admin/v1/provider_keys/:id) fired -// mid-stream keeps every request serving and bumps the resource -// revision. The caller's api_key and model alias are never touched. +// an in-place secret rotation (a declarative update to the provider_key +// document — same id + api_base, new secret) fired mid-stream keeps every +// request serving, and the rotated secret lands in the store. The caller's +// api_key and model alias are never touched. // // IMPORTANT scope note (from the #523 audit): "zero in-flight // disruption" is largely an ARCHITECTURAL guarantee here, NOT a property // this test could falsify. The DP holds one shared upstream client and // reads `pk.secret` / `pk.api_base` per-request from an atomic ArcSwap // snapshot; an in-flight request keeps its own snapshot Arc to -// completion and a watch-applied PUT CAS-swaps a fresh snapshot — there -// is no per-provider_key client or pool to tear down. So this is a +// completion and a watch-applied update CAS-swaps a fresh snapshot — +// there is no per-provider_key client or pool to tear down. So this is a // liveness/smoke pin over the rotate-under-load path (it would catch a // future regression that wedged dispatch or broke watch-apply on a PK -// PUT) plus a revision-bump check — it is not a teardown-race probe. +// update) plus a store read-back check — it is not a teardown-race probe. // // The real remaining facet is #220: asserting the rotated secret // actually reaches upstream (old rejected / new accepted). The mock @@ -41,8 +42,8 @@ import { // tracked separately, not closed by this test. // // Reference: OpenAI Chat Completions shape the caller sees -// (https://platform.openai.com/docs/api-reference/chat); admin -// provider_key update is PUT /admin/v1/provider_keys/:id. +// (https://platform.openai.com/docs/api-reference/chat). The rotation is +// an in-place update of the provider_key document (same id, new secret). const CALLER_PLAINTEXT = "sk-pkrot-e2e-caller"; const CALLER_KEY_HASH = createHash("sha256") From eafbbbb25bf85df9967fb07175c35870e6e7e9e7 Mon Sep 17 00:00:00 2001 From: Ming Wen Date: Tue, 21 Jul 2026 17:35:39 +0800 Subject: [PATCH 3/3] test(e2e): check res.ok before parsing /status/config in the guardrail gate CodeRabbit: fetch does not throw on HTTP error status; guard the readiness poll with res.ok before .json() so a transient non-200 retries cleanly instead of parsing an error body. --- tests/e2e/src/cases/guardrail-disabled-bypass-e2e.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/e2e/src/cases/guardrail-disabled-bypass-e2e.test.ts b/tests/e2e/src/cases/guardrail-disabled-bypass-e2e.test.ts index afaf0036..f00b4860 100644 --- a/tests/e2e/src/cases/guardrail-disabled-bypass-e2e.test.ts +++ b/tests/e2e/src/cases/guardrail-disabled-bypass-e2e.test.ts @@ -126,9 +126,9 @@ describe("guardrail disabled-bypass e2e: enabled:false → no block", () => { // response means the dispatcher is ready. await waitConfigPropagation(async () => { try { - const cfg = (await ( - await fetch(`${app!.metricsUrl}/status/config`) - ).json()) as { + const res = await fetch(`${app!.metricsUrl}/status/config`); + if (!res.ok) return false; + const cfg = (await res.json()) as { applied?: { resource_counts?: Record }; }; if ((cfg.applied?.resource_counts?.guardrails ?? 0) < 1) {