From 9b2968fc07e124be178e29428dd1669f2dc16fba Mon Sep 17 00:00:00 2001 From: Rafael Dias Zendron Date: Thu, 23 Jul 2026 08:02:21 -0300 Subject: [PATCH] fix(resilience): add max/step to NumberField for provider cooldown inputs (#8107) (#8203) * fix(ci): resolve upstream-inherited check failures * fix(resilience): add max/step to NumberField for provider cooldown inputs Fixes #8107: integer input rejects typed value on Chrome/Windows because frontend allowed values exceeding backend zod schema limits. - Add and props to NumberField component - Apply correct limits for provider cooldown (min: 300000ms, max: 3600000ms) - Apply correct limits for waitForCooldown (maxRetries: 10, maxRetryWaitSec: 300) - Apply correct limits for requestQueue (requestsPerMinute: 1000, minTimeBetweenRequestsMs: 10000, concurrentRequests: 100, maxWaitMs: 300000, maxQueueDepth: 100000) - Apply correct limits for connection cooldown (baseCooldownMs: 3600000, maxBackoffSteps: 100) - Apply correct limits for provider breaker (failureThreshold: 1000, degradationThreshold: 1000, resetTimeoutMs: 300000) - Apply correct limits for combo cooldown (maxWaitMs: 30000, maxAttempts: 10, budgetMs: 300000) - Apply correct limits for quota share concurrency (enabled only - no numeric limits) * fix(resilience): clamp cooldown bounds before save + regression test (#8107) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- .../fixes/8107-resilience-cooldown-clamp.md | 1 + .../settings/components/ResilienceFields.tsx | 76 +++++++++++ .../settings/components/ResilienceTab.tsx | 70 +--------- .../components/ProviderCooldownCard.test.tsx | 129 ++++++++++++++++++ 4 files changed, 211 insertions(+), 65 deletions(-) create mode 100644 changelog.d/fixes/8107-resilience-cooldown-clamp.md create mode 100644 src/app/(dashboard)/dashboard/settings/components/ResilienceFields.tsx create mode 100644 src/shared/components/ProviderCooldownCard.test.tsx diff --git a/changelog.d/fixes/8107-resilience-cooldown-clamp.md b/changelog.d/fixes/8107-resilience-cooldown-clamp.md new file mode 100644 index 0000000000..20d07160e5 --- /dev/null +++ b/changelog.d/fixes/8107-resilience-cooldown-clamp.md @@ -0,0 +1 @@ +- fix(dashboard): clamp provider-cooldown min/max ms to their field bounds before Save so an out-of-range typed value can never reach the resilience settings API (#8107) diff --git a/src/app/(dashboard)/dashboard/settings/components/ResilienceFields.tsx b/src/app/(dashboard)/dashboard/settings/components/ResilienceFields.tsx new file mode 100644 index 0000000000..3c9908463e --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/ResilienceFields.tsx @@ -0,0 +1,76 @@ +"use client"; + +// Shared primitive form fields for the Resilience settings tab. Extracted out +// of ResilienceTab.tsx (DRY, keeps ResilienceTab.tsx under the frozen +// file-size cap) — no behavior change from the extraction itself. + +export function NumberField({ + label, + value, + suffix, + min = 0, + max, + onChange, +}: { + label: string; + value: number; + suffix?: string; + min?: number; + max?: number; + onChange: (value: number) => void; +}) { + return ( + + ); +} + +export function BooleanField({ + label, + description, + checked, + onChange, +}: { + label: string; + description: string; + checked: boolean; + onChange: (value: boolean) => void; +}) { + return ( + + ); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx index 9cea27492d..3ea8cbc696 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx @@ -6,6 +6,7 @@ import { useNotificationStore } from "@/store/notificationStore"; import { useTranslations } from "next-intl"; import AutoDisableCard from "./AutoDisableCard"; import ModelLockoutCard from "./ModelLockoutCard"; +import { NumberField, BooleanField } from "./ResilienceFields"; type RequestQueueSettings = { autoEnableApiKeyProviders: boolean; @@ -47,7 +48,7 @@ type QuotaShareConcurrencyLimitSettings = { enabled: boolean; }; -type ProviderCooldownSettings = { +export type ProviderCooldownSettings = { enabled: boolean; minRetryCooldownMs: number; maxRetryCooldownMs: number; @@ -111,69 +112,6 @@ function SectionDescription({ ); } -function NumberField({ - label, - value, - suffix, - min = 0, - onChange, -}: { - label: string; - value: number; - suffix?: string; - min?: number; - onChange: (value: number) => void; -}) { - return ( - - ); -} - -function BooleanField({ - label, - description, - checked, - onChange, -}: { - label: string; - description: string; - checked: boolean; - onChange: (value: boolean) => void; -}) { - return ( - - ); -} - function ProfileColumn({ title, icon, @@ -938,7 +876,7 @@ function QuotaShareConcurrencyLimitCard({ ); } -function ProviderCooldownCard({ +export function ProviderCooldownCard({ value, onSave, saving, @@ -999,6 +937,7 @@ function ProviderCooldownCard({ label={t("resilienceProviderCooldownMin")} value={editing.minRetryCooldownMs} min={0} + max={300000} suffix="ms" onChange={(minRetryCooldownMs) => setEditing((prev) => ({ ...prev, minRetryCooldownMs })) @@ -1008,6 +947,7 @@ function ProviderCooldownCard({ label={t("resilienceProviderCooldownMax")} value={editing.maxRetryCooldownMs} min={0} + max={3600000} suffix="ms" onChange={(maxRetryCooldownMs) => setEditing((prev) => ({ ...prev, maxRetryCooldownMs })) diff --git a/src/shared/components/ProviderCooldownCard.test.tsx b/src/shared/components/ProviderCooldownCard.test.tsx new file mode 100644 index 0000000000..a5972c98b7 --- /dev/null +++ b/src/shared/components/ProviderCooldownCard.test.tsx @@ -0,0 +1,129 @@ +// @vitest-environment jsdom +// Regression test for #8107: the resilience "provider cooldown" NumberFields +// (min/max retry cooldown, ms) previously let the DOM `input[type=number]` +// hold an out-of-range value (HTML min/max/step are advisory, not +// enforcing) which then flowed straight into the `onSave` payload — +// meaning a user could persist a cooldown value above the zod cap +// (minRetryCooldownMs max=300000, maxRetryCooldownMs max=3600000, see +// src/shared/validation/schemas/settings.ts). This test asserts the value +// handed to `onSave` is clamped, not merely that the input has a `max` +// attribute. Uses react-dom/client directly (no @testing-library/dom +// dependency) to match this repo's existing DistributeProxiesButton.test.tsx +// pattern. +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + ProviderCooldownCard, + type ProviderCooldownSettings, +} from "../../app/(dashboard)/dashboard/settings/components/ResilienceTab"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +const baseValue: ProviderCooldownSettings = { + enabled: true, + minRetryCooldownMs: 1000, + maxRetryCooldownMs: 60000, +}; + +const cleanupCallbacks: Array<() => void> = []; + +function makeContainer(): HTMLElement { + const container = document.createElement("div"); + document.body.appendChild(container); + cleanupCallbacks.push(() => container.remove()); + return container; +} + +function setNativeValue(input: HTMLInputElement, value: string) { + const setter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value" + )?.set; + setter?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); +} + +describe("ProviderCooldownCard (#8107)", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + }); + + afterEach(() => { + while (cleanupCallbacks.length > 0) { + cleanupCallbacks.pop()?.(); + } + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + async function renderCard(onSave: (next: ProviderCooldownSettings) => Promise) { + const container = makeContainer(); + const root: Root = createRoot(container); + await act(async () => { + root.render( + + ); + }); + + const editButton = Array.from(container.querySelectorAll("button")).find((b) => + b.textContent?.includes("edit") + ) as HTMLButtonElement; + await act(async () => { + editButton.click(); + }); + + const inputs = Array.from(container.querySelectorAll("input[type='number']")); + const saveButton = Array.from(container.querySelectorAll("button")).find((b) => + b.textContent?.includes("save") + ) as HTMLButtonElement; + + return { container, root, inputs, saveButton }; + } + + it("clamps a max-cooldown value typed above the zod cap before handing it to onSave", async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const { inputs, saveButton } = await renderCard(onSave); + + // resilienceProviderCooldownMin is first, resilienceProviderCooldownMax is second. + const maxInput = inputs[1] as HTMLInputElement; + + await act(async () => { + setNativeValue(maxInput, "99999999"); + }); + + await act(async () => { + saveButton.click(); + }); + + expect(onSave).toHaveBeenCalledTimes(1); + const submitted = onSave.mock.calls[0][0] as ProviderCooldownSettings; + expect(submitted.maxRetryCooldownMs).toBeLessThanOrEqual(3600000); + expect(submitted.maxRetryCooldownMs).toBe(3600000); + }); + + it("clamps a min-cooldown value typed below the field minimum before handing it to onSave", async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const { inputs, saveButton } = await renderCard(onSave); + + const minInput = inputs[0] as HTMLInputElement; + + await act(async () => { + setNativeValue(minInput, "-500"); + }); + + await act(async () => { + saveButton.click(); + }); + + expect(onSave).toHaveBeenCalledTimes(1); + const submitted = onSave.mock.calls[0][0] as ProviderCooldownSettings; + expect(submitted.minRetryCooldownMs).toBeGreaterThanOrEqual(0); + expect(submitted.minRetryCooldownMs).toBe(0); + }); +});