fix(ui): omit blank expiry from key edit and regenerate payloads (#48634)

* fix(ui): omit blank expiry from key edit and regenerate payloads

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* chore: credit #45048 author

Co-authored-by: Thomas Scholtz <thomas@quantum-sicarius.za.net>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* style(ui): format regenerate key payload builder

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(ui): compare regenerate payloads by fields, not JSON order

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Thomas Scholtz <thomas@quantum-sicarius.za.net>
This commit is contained in:
moe-berri 2026-10-27 17:03:12 -07:00 • committed by GitHub
parent 59b8c73930
commit 1b932332c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 93 additions and 29 deletions

View file

@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils";
import { RegenerateKeyModal } from "./RegenerateKeyModal";
@ -58,16 +59,22 @@ describe("RegenerateKeyModal submit payload", () => {
});
});
it("keeps the wire body's key order and drops nothing through JSON serialisation", async () => {
it("submits the expected regenerated key fields", async () => {
const user = userEvent.setup();
const expectedPayload = {
key_alias: "my-test-key",
max_budget: 100,
tpm_limit: 5000,
rpm_limit: 500,
grace_period: "",
duration: "30d",
};
renderModal();
await regenerate(user);
await waitFor(() => expect(mockRegenerateKeyCall).toHaveBeenCalledOnce());
expect(JSON.stringify(submittedPayload())).toBe(
'{"key_alias":"my-test-key","max_budget":100,"tpm_limit":5000,"rpm_limit":500,"duration":"30d","grace_period":""}',
);
expect(submittedPayload()).toEqual(expectedPayload);
});
it("rounds max_budget to two decimals and sends the other numbers unrounded", async () => {
@ -138,7 +145,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("omits a cleared duration while sending null for cleared numbers", async () => {
const user = userEvent.setup();
renderModal();
@ -155,7 +162,6 @@ describe("RegenerateKeyModal submit payload", () => {
max_budget: null,
tpm_limit: null,
rpm_limit: null,
duration: "",
grace_period: "",
});
});
@ -180,10 +186,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.parse(JSON.stringify(submittedPayload()))).toEqual({ grace_period: "" });
});
it("regenerates a key whose fields the API returned as null", async () => {
@ -206,11 +211,21 @@ describe("RegenerateKeyModal submit payload", () => {
max_budget: null,
tpm_limit: null,
rpm_limit: null,
duration: "",
grace_period: "",
});
});
it("sends a typed duration when the original key has no stored duration", async () => {
const user = userEvent.setup();
renderModal(makeToken({ duration: undefined }));
fireEvent.change(screen.getByPlaceholderText("e.g. 30s, 30h, 30d"), { target: { value: "30d" } });
await regenerate(user);
await waitFor(() => expect(mockRegenerateKeyCall).toHaveBeenCalledOnce());
expect(submittedPayload()).toHaveProperty("duration", "30d");
});
it("targets the key by its token hash", async () => {
const user = userEvent.setup();
renderModal();

View file

@ -62,6 +62,13 @@ describe("buildRegenerateKeyPayload", () => {
});
});
it.each(["", " "])("omits blank duration %j from the payload and JSON body", (duration) => {
const payload = buildRegenerateKeyPayload(values({ duration }));
expect(payload).not.toHaveProperty("duration");
expect(JSON.stringify(payload)).not.toContain('"duration"');
});
it.each([
["null", null],
["undefined", undefined],
@ -82,18 +89,10 @@ describe("buildRegenerateKeyPayload", () => {
duration: "30d",
grace_period: "",
});
expect(JSON.stringify(payload)).toBe('{"duration":"30d","grace_period":""}');
});
it("preserves the field order the proxy request body is serialised in", () => {
expect(Object.keys(buildRegenerateKeyPayload(values({ max_budget: 1.239 })))).toStrictEqual([
"key_alias",
"max_budget",
"tpm_limit",
"rpm_limit",
"duration",
"grace_period",
]);
expect(JSON.parse(JSON.stringify(payload))).toEqual({
grace_period: "",
duration: "30d",
});
});
it("does not mutate the values it is handed", () => {

View file

@ -9,6 +9,8 @@ export interface RegenerateKeyFormValues {
grace_period: string;
}
export type RegenerateKeyPayload = Omit<RegenerateKeyFormValues, "duration"> & { duration?: string };
const shiftExponent = (value: number, places: number): number => {
const [mantissa, exponent = "0"] = value.toExponential().split("e");
return Number(`${mantissa}e${Number(exponent) + places}`);
@ -21,10 +23,13 @@ export const roundToPrecision = (value: number, precision: number): number => {
return value < 0 ? -rounded : rounded;
};
export const buildRegenerateKeyPayload = (values: RegenerateKeyFormValues): RegenerateKeyFormValues => ({
...values,
max_budget:
typeof values.max_budget === "number"
? roundToPrecision(values.max_budget, MAX_BUDGET_PRECISION)
: values.max_budget,
});
export const buildRegenerateKeyPayload = (values: RegenerateKeyFormValues): RegenerateKeyPayload => {
const { duration, ...rest } = values;
return {
...rest,
max_budget:
typeof rest.max_budget === "number" ? roundToPrecision(rest.max_budget, MAX_BUDGET_PRECISION) : rest.max_budget,
...(duration.trim() !== "" ? { duration } : {}),
};
};

View file

@ -53,3 +53,17 @@ describe("keyEditFormSchema", () => {
expect(parse({ default_estimated_output_tokens_per_model: '{"gpt-4": 4096}' }).success).toBe(true);
});
});
describe("duration submission", () => {
const submit = (duration: string | null) =>
toSubmittedValues({ duration }, { canViewPolicies: false, canViewPrompts: false });
it.each(["", " "])("omits a blank duration value %j", (duration) => {
expect(submit(duration)).not.toHaveProperty("duration");
});
it("preserves a typed duration and null", () => {
expect(submit("30d")).toHaveProperty("duration", "30d");
expect(submit(null)).toHaveProperty("duration", null);
});
});

View file

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

View file

@ -2542,6 +2542,37 @@ describe("KeyEditView", () => {
expect(onSubmitMock.mock.calls[0][0]).toHaveProperty("duration", null);
});
it("omits an untouched blank duration for an expiring key", async () => {
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
renderForPayload(onSubmitMock, {
...MOCK_KEY_DATA,
expires: "2026-12-31T00:00:00Z",
duration: undefined,
});
await screen.findByRole("button", { name: /save changes/i });
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(onSubmitMock).toHaveBeenCalledTimes(1));
expect(onSubmitMock.mock.calls[0][0]).not.toHaveProperty("duration");
});
it("submits a typed expiry duration", async () => {
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
renderForPayload(onSubmitMock, {
...MOCK_KEY_DATA,
expires: "2026-12-31T00:00:00Z",
duration: undefined,
});
await screen.findByRole("button", { name: /save changes/i });
fireEvent.change(screen.getByPlaceholderText("e.g., 30d"), { target: { value: "30d" } });
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(onSubmitMock).toHaveBeenCalledTimes(1));
expect(onSubmitMock.mock.calls[0][0]).toHaveProperty("duration", "30d");
});
it("carries a typed value from every free-text and numeric control into the payload", async () => {
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
renderForPayload(onSubmitMock);