fix(ui): omit blank expiry from key payloads

The key edit form and the Regenerate Key dialog sent `duration: ""` when the expiry field was left blank. With `upperbound_key_generate_params.duration` set, the proxy's upperbound check parses it and rejects the request with "Invalid duration format".

Drop `duration` from both payload builders when it is blank, so the request follows the update API contract: omitted leaves the expiry unchanged, null never expires, a value sets it
This commit is contained in:
Thomas Scholtz 2026-10-07 11:51:24 +02:00
parent 736ff14f11
commit a4ca2e1f2a
6 changed files with 52 additions and 8 deletions

View file

@ -138,7 +138,7 @@ describe("RegenerateKeyModal submit payload", () => {
expect(submittedPayload()[key]).toBe(12.7);
});
it("sends null for cleared numbers and an empty string for a cleared duration", async () => {
it("sends null for cleared numbers and omits a cleared duration", async () => {
const user = userEvent.setup();
renderModal();
@ -155,7 +155,6 @@ describe("RegenerateKeyModal submit payload", () => {
max_budget: null,
tpm_limit: null,
rpm_limit: null,
duration: "",
grace_period: "",
});
});
@ -180,10 +179,9 @@ describe("RegenerateKeyModal submit payload", () => {
max_budget: undefined,
tpm_limit: undefined,
rpm_limit: undefined,
duration: "",
grace_period: "",
});
expect(JSON.stringify(submittedPayload())).toBe('{"duration":"","grace_period":""}');
expect(JSON.stringify(submittedPayload())).toBe('{"grace_period":""}');
});
it("regenerates a key whose fields the API returned as null", async () => {
@ -206,7 +204,6 @@ describe("RegenerateKeyModal submit payload", () => {
max_budget: null,
tpm_limit: null,
rpm_limit: null,
duration: "",
grace_period: "",
});
});

View file

@ -96,6 +96,17 @@ describe("buildRegenerateKeyPayload", () => {
]);
});
it("omits a blank duration so the proxy leaves the expiry unchanged", () => {
const payload = buildRegenerateKeyPayload(values({ duration: "" }));
expect(payload).not.toHaveProperty("duration");
expect(JSON.stringify(payload)).not.toContain("duration");
});
it("keeps a typed duration", () => {
expect(buildRegenerateKeyPayload(values({ duration: "30d" })).duration).toBe("30d");
});
it("does not mutate the values it is handed", () => {
const original = values({ max_budget: 42.567 });

View file

@ -21,10 +21,16 @@ export const roundToPrecision = (value: number, precision: number): number => {
return value < 0 ? -rounded : rounded;
};
export const buildRegenerateKeyPayload = (values: RegenerateKeyFormValues): RegenerateKeyFormValues => ({
...values,
export type RegenerateKeyPayload = Omit<RegenerateKeyFormValues, "duration"> & { duration?: string };
export const buildRegenerateKeyPayload = (values: RegenerateKeyFormValues): RegenerateKeyPayload => ({
key_alias: values.key_alias,
max_budget:
typeof values.max_budget === "number"
? roundToPrecision(values.max_budget, MAX_BUDGET_PRECISION)
: values.max_budget,
tpm_limit: values.tpm_limit,
rpm_limit: values.rpm_limit,
...(values.duration === "" ? {} : { duration: values.duration }),
grace_period: values.grace_period,
});

View file

@ -25,6 +25,22 @@ describe("tpd_limit round trip", () => {
});
});
describe("duration in the submitted payload", () => {
const submit = (duration: string | null) =>
toSubmittedValues(
{ ...toKeyEditFormValues({ token: "tok", models: [] } as unknown as KeyResponse), duration },
{ canViewPolicies: true, canViewPrompts: true },
);
it("omits a blank duration so the proxy leaves the expiry unchanged", () => {
expect(submit("")).not.toHaveProperty("duration");
});
it.each([["30d"], [null]])("keeps duration %s", (duration) => {
expect(submit(duration)).toHaveProperty("duration", duration);
});
});
describe("keyEditFormSchema", () => {
it("accepts an empty form", () => {
expect(parse({}).success).toBe(true);

View file

@ -213,7 +213,7 @@ export const toSubmittedValues = (
team_id: values.team_id,
logging_settings: values.logging_settings,
metadata: values.metadata,
duration: values.duration,
...(values.duration === "" ? {} : { duration: values.duration }),
token: values.token,
disabled_callbacks: values.disabled_callbacks,
auto_rotate: values.auto_rotate,

View file

@ -2218,6 +2218,20 @@ describe("KeyEditView", () => {
expect(onSubmitMock.mock.calls[0][0]).toHaveProperty("duration", null);
});
it("untouched expiry sends no duration key", async () => {
const { duration: _storedDuration, ...keyWithoutDuration } = MOCK_KEY_DATA;
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
renderForPayload(onSubmitMock, { ...keyWithoutDuration, expires: "2030-01-01T00:00:00Z" } as KeyResponse);
await screen.findByRole("button", { name: /save changes/i });
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalled();
});
expect(onSubmitMock.mock.calls[0][0]).not.toHaveProperty("duration");
});
it("carries a typed value from every free-text and numeric control into the payload", async () => {
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
renderForPayload(onSubmitMock);