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); + }); +});