fix(custom-widgets): require explicit request bindings

This commit is contained in:
Thomas Camlong committed 2026-07-20 17:02:59 +02:00
1 parent 4c69ad90df
commit a26846fa31
23 files changed
+363 -52

No files matched your search

@@ -32,6 +32,9 @@ describe("buildCustomWidgetAiPrompt", () => {
expect(prompt).toContain("homarr-custom-widget-v2");
expect(prompt).toContain("Return exactly two fenced blocks");
expect(prompt).toContain("Never include credentials");
expect(prompt).toContain("Every declared parameter needs an explicit value source");
expect(prompt).toContain('"optionsBinding"');
expect(prompt).toContain("Never infer a binding from matching names");
expect(prompt).toContain("Create a Portainer widget");
expect(prompt.length).toBeLessThanOrEqual(8_000);
});
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { resolveCustomWidgetOptionsBinding } from "../core";
import type { CustomJsxRequest } from "../core";
import { hashRuntimeParams, renderRequestBody, renderRequestTarget, validateRuntimeParams } from "../server";
@@ -58,4 +59,28 @@ describe("named request manifest rendering", () => {
it("hashes parameters independently of object insertion order", () => {
expect(hashRuntimeParams({ a: 1, b: "two" })).toBe(hashRuntimeParams({ b: "two", a: 1 }));
});
it("resolves only explicit option and literal bindings for automatic requests", () => {
const loadRequest: CustomJsxRequest = {
...request,
kind: "query",
trigger: "load",
optionsBinding: {
device: { $option: "selectedDevice" },
enabled: true,
level: { $option: "volume" },
},
};
expect(resolveCustomWidgetOptionsBinding(loadRequest, { selectedDevice: "lamp", volume: 5 })).toEqual({
device: "lamp",
enabled: true,
level: 5,
});
expect(() => resolveCustomWidgetOptionsBinding({ ...loadRequest, optionsBinding: undefined }, {})).toThrow(
"has no explicit option binding",
);
expect(() => resolveCustomWidgetOptionsBinding(loadRequest, { device: "lamp", level: 5 })).toThrow(
"option 'selectedDevice'",
);
});
});
@@ -83,6 +83,116 @@ describe("Custom JSX v2 validation", () => {
).toBe(true);
});
test("requires explicit parameter sources for load queries", () => {
const optionsSchema = {
type: "object" as const,
properties: {
endpointId: { type: "string" },
wrongType: { type: "boolean" },
},
required: ["endpointId"],
additionalProperties: false as const,
};
const request = {
id: "containers",
sourceId: "default",
kind: "query" as const,
method: "GET" as const,
pathTemplate: "/endpoints/{endpointId}/containers",
parameters: { endpointId: "string" as const, showAll: "boolean" as const },
queryTemplate: { all: { $param: "showAll" } },
auth: "none" as const,
minimumBoardPermission: "view" as const,
trigger: "load" as const,
};
const missing = customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
requests: [request],
optionsSchema,
defaultOptions: { endpointId: "local" },
});
expect(missing.success).toBe(false);
if (missing.success) throw new Error("Expected missing bindings to fail");
expect(missing.error.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: ["requests", 0, "optionsBinding", "endpointId"],
message: expect.stringContaining("explicit option reference or literal"),
}),
expect.objectContaining({ path: ["requests", 0, "optionsBinding", "showAll"] }),
]),
);
expect(
customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
requests: [
{
...request,
optionsBinding: { endpointId: { $option: "endpointId" }, showAll: false },
},
],
optionsSchema,
defaultOptions: { endpointId: "local" },
}).success,
).toBe(true);
expect(
customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
requests: [
{
...request,
optionsBinding: { endpointId: { $option: "wrongType" }, showAll: false },
},
],
optionsSchema,
defaultOptions: { endpointId: "local" },
}).success,
).toBe(false);
});
test("requires invoking components to provide params for parameterized manual requests", () => {
const request = {
id: "detail",
sourceId: "default",
kind: "query" as const,
method: "GET" as const,
pathTemplate: "/items/{id}",
parameters: { id: "string" as const },
auth: "none" as const,
minimumBoardPermission: "view" as const,
trigger: "manual" as const,
};
expect(
customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
requests: [request],
template: '<SubFetch requestId="detail" />',
}).success,
).toBe(false);
expect(
customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
requests: [request],
template: '<SubFetch requestId="detail" params={{ id: inputs.selectedId }} />',
}).success,
).toBe(true);
expect(
customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
requests: [
{
...request,
trigger: "load",
optionsBinding: { id: "fixed" },
},
],
template: '<SubFetch requestId="detail" params={{ id: inputs.selectedId }} />',
}).success,
).toBe(false);
});
test("rejects reserved headers, duplicate IDs, and unknown sources", () => {
const request = {
id: "status",
@@ -150,6 +260,7 @@ describe("Custom JSX v2 validation", () => {
method: "POST",
pathTemplate: "/api/v3/calendar",
parameters: { start: "string" },
optionsBinding: { start: { $option: "start" } },
bodyTemplate: { start: { $param: "start" } },
auth: "inherit",
minimumBoardPermission: "view",