fix(custom-widgets): address v2 merge review (#6446)

This commit is contained in:
Thomas authored and GitHub committed 2026-07-23 01:55:15 +02:00
1 parent 6aceb2fd1f
commit df967751f6
123 files changed
+8632 -1477

No files matched your search

@@ -3,7 +3,7 @@ import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { validateCustomJsxTemplate } from "../jsx/analyzer";
import { createCustomJsxBindings } from "../jsx/bindings";
import { createCustomJsxBindings, CUSTOM_JSX_DATA_LIMITS } from "../jsx/bindings";
import { renderSafeJsx, sanitizeCustomJsxProps } from "../jsx/interpreter";
import { CUSTOM_JSX_BLOCKED_PROPERTIES, CUSTOM_JSX_BLOCKED_PROPS, CUSTOM_JSX_LIMITS } from "../jsx/policy";
@@ -221,6 +221,37 @@ describe("shared Custom JSX policy", () => {
expect(diagnostics.some(({ message }) => message.includes("Prop 'bind'"))).toBe(false);
});
test("validates explicit bind attributes through the JSX AST", () => {
expect(
validateCustomJsxTemplate('<TextInput bind="search" />').filter(({ severity }) => severity === "error"),
).toEqual([]);
expect(validateCustomJsxTemplate("<TextInput bind={data.name} />")).toEqual(
expect.arrayContaining([
expect.objectContaining({ severity: "error", message: expect.stringContaining("literal input name") }),
]),
);
expect(validateCustomJsxTemplate('<TextInput bind="not valid" />')).toEqual(
expect.arrayContaining([
expect.objectContaining({ severity: "error", message: expect.stringContaining("Invalid bind input name") }),
]),
);
expect(
validateCustomJsxTemplate('<Text>{"<TextInput bind={data.name} />"}</Text>').some(({ message }) =>
message.includes("literal input name"),
),
).toBe(false);
});
test("rejects bind values introduced by JSX spreads at the emitter boundary", () => {
expect(() =>
renderSafeJsx({
template: "<TextInput {...data.props} />",
components: { TextInput: (() => null) as never },
bindings: { data: { props: { bind: "remote-name", label: "Unsafe" } } },
}),
).toThrow("BIND_SPREAD_NOT_ALLOWED");
});
test("provides closest component and binding diagnostics", () => {
expect(validateCustomJsxTemplate("<Stak />")[0]?.message).toMatch(/UNKNOWN_COMPONENT.*Stack/u);
expect(validateCustomJsxTemplate('<Text bind="search" />')).toEqual(
@@ -305,9 +336,34 @@ describe("shared Custom JSX policy", () => {
expect(renderToStaticMarkup(rendered.node)).toContain("Bulba-saur-");
});
test.each(["/(a+)+$/", "/(?<=a)b/", "/(a)\\1/"])("rejects unsafe regex %s", (pattern) => {
expect(validateCustomJsxTemplate(`<Text>{${pattern}.test(data.name)}</Text>`)).toEqual(
expect.arrayContaining([expect.objectContaining({ message: expect.stringContaining("UNSAFE_REGEX") })]),
test.each(["/(a+)+$/", "/(?<=a)b/", "/(a)\\1/", "/^(a|aa)+$/", "/^(a|a?)+$/", "/^a+a+$/", "/^(\\w+\\s?)*$/"])(
"rejects unsafe regex %s in analysis and at runtime",
(pattern) => {
const template = `<Text>{${pattern}.test(data.name)}</Text>`;
expect(validateCustomJsxTemplate(template)).toEqual(
expect.arrayContaining([expect.objectContaining({ message: expect.stringContaining("UNSAFE_REGEX") })]),
);
expect(() =>
renderSafeJsx({ template, components, bindings: { data: { name: "a".repeat(10_000) + "!" } } }),
).toThrow("UNSAFE_REGEX");
},
);
test("allows a single unambiguous variable regex quantifier", () => {
const template = '<Text>{/^(ab)+$/.test(data.name) ? "yes" : "no"}</Text>';
expect(validateCustomJsxTemplate(template).filter(({ severity }) => severity === "error")).toEqual([]);
expect(() => renderSafeJsx({ template, components, bindings: { data: { name: "abab" } } })).not.toThrow();
});
test("bounds response data depth, node count, and string length without recursive traversal", () => {
let deeplyNested: unknown = null;
for (let depth = 0; depth <= CUSTOM_JSX_DATA_LIMITS.depth; depth += 1) deeplyNested = { child: deeplyNested };
expect(() => createCustomJsxBindings(deeplyNested)).toThrow(/depth limit/u);
expect(() => createCustomJsxBindings(Array.from({ length: CUSTOM_JSX_DATA_LIMITS.nodes }).fill(null))).toThrow(
/node limit/u,
);
expect(() => createCustomJsxBindings("x".repeat(CUSTOM_JSX_DATA_LIMITS.stringLength + 1))).toThrow(
/string length limit/u,
);
});
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { customWidgetDefinitionSchema } from "../core";
import { CustomWidgetPreviewSessionService } from "../server";
import type { PreviewSessionStore } from "../server";
const definition = customWidgetDefinitionSchema.parse({
$schema: "homarr-custom-widget-v2",
@@ -109,4 +110,83 @@ describe("preview sessions", () => {
),
).rejects.toThrow("authentication changed");
});
it("does not delete a preview when a different user looks it up", async () => {
const values = new Map<string, unknown>();
const store: PreviewSessionStore = {
saveSession: async (id, value) => {
values.set(id, value);
},
compareAndSwapSession: async (id, revision, value) => {
const current = values.get(id) as { revision?: number } | undefined;
if (current?.revision !== revision) return false;
values.set(id, value);
return true;
},
getSession: async (id) => values.get(id),
deleteSession: async (id) => {
values.delete(id);
},
appendJournal: async () => undefined,
getJournal: async () => [],
};
const service = new CustomWidgetPreviewSessionService({
createId: () => "session",
encrypt: String,
decrypt: String,
store,
});
await service.create({
userId: "owner",
sources: definition.sources,
requests: definition.requests,
name: definition.name,
template: definition.template,
optionDefinitions: definition.options,
options: { limit: 10 },
secrets: [],
});
await expect(service.get("session", "other-user")).rejects.toMatchObject({ code: "NOT_FOUND" });
await expect(service.get("session", "owner")).resolves.toMatchObject({ id: "session" });
});
it("retries concurrent mutations without losing source, secret, or action state", async () => {
const service = new CustomWidgetPreviewSessionService({
createId: () => "session",
encrypt: (value) => `encrypted:${value}`,
decrypt: (value) => value.replace("encrypted:", ""),
});
await service.create({
userId: "owner",
sources: {
...definition.sources,
secondary: { baseUrl: "https://secondary.example.com", networkScope: "public", auth: "basic" },
},
requests: definition.requests,
name: definition.name,
template: definition.template,
optionDefinitions: definition.options,
options: { limit: 10 },
secrets: [],
});
await Promise.all([
service.setLiveActions("session", "owner", true),
service.setSecrets("session", "owner", [{ sourceId: "secondary", kind: "username", value: "operator" }]),
service.configureSource(
"session",
"owner",
"default",
{ baseUrl: "https://api.example.com", networkScope: "public", auth: "bearer" },
[{ sourceId: "default", kind: "apiKey", value: "token" }],
),
]);
const session = await service.get("session", "owner");
expect(session.liveActions).toBe(true);
expect(session.sources.default?.baseUrl).toBe("https://api.example.com");
expect(service.getSecrets(session, "default")).toEqual([{ kind: "apiKey", value: "token" }]);
expect(service.getSecrets(session, "secondary")).toEqual([{ kind: "username", value: "operator" }]);
});
});
@@ -1,11 +1,19 @@
import { describe, expect, it, vi } from "vitest";
import { CustomWidgetRequestLimiter } from "../server";
import {
CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS,
CustomWidgetRequestLimiter,
MAX_REQUEST_DURATION_MS,
} from "../server";
import type { RequestLimitStore } from "../server";
const input = { category: "action", userId: "user", itemId: "item", definitionId: "definition" } as const;
describe("custom widget request limits", () => {
it("keeps owned concurrency leases beyond the total request deadline", () => {
expect(CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS).toBeGreaterThan(MAX_REQUEST_DURATION_MS);
});
it("enforces local per-user/item concurrency and releases capacity", async () => {
const limiter = new CustomWidgetRequestLimiter();
const releases = await Promise.all(Array.from({ length: 4 }, () => limiter.acquire(input)));
@@ -39,4 +47,31 @@ describe("custom widget request limits", () => {
});
expect(store.releaseConcurrency).toHaveBeenCalledOnce();
});
it("releases only the concurrency lease owned by the request", async () => {
const owners = new Set<string>();
const store: RequestLimitStore = {
incrementRate: vi.fn(async () => ({ count: 1, retryAfterMs: 60_000 })),
acquireConcurrency: vi.fn(async (_key, ownerId) => {
owners.add(ownerId);
return true;
}),
releaseConcurrency: vi.fn(async (_key, ownerId) => {
owners.delete(ownerId);
}),
};
const releaseFirst = await new CustomWidgetRequestLimiter({ store }).acquire(input);
const firstOwner = [...owners][0];
expect(store.acquireConcurrency).toHaveBeenCalledWith(
expect.any(String),
firstOwner,
expect.any(Number),
CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS,
);
await releaseFirst();
expect(firstOwner).toBeTypeOf("string");
expect(store.releaseConcurrency).toHaveBeenCalledWith(expect.any(String), firstOwner);
expect(owners).not.toContain(firstOwner);
});
});
@@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ActionButton, CustomJsxRenderer, CustomWidgetRuntimeProvider, SubFetch, ToggleSwitch } from "../runtime";
import { createCustomJsxComponents } from "../jsx";
import { MAX_REFRESH_INTERVAL_MS, MAX_REFRESH_INTERVAL_SECONDS, normalizeRefreshInterval } from "../runtime/sub-fetch";
import type {
CustomJsxRequestCapability,
CustomWidgetPublishedQueryState,
@@ -106,6 +107,31 @@ async function settle() {
}
describe("Custom Widget runtime ports", () => {
it("clamps refresh intervals below the browser timer overflow boundary", () => {
expect(normalizeRefreshInterval(MAX_REFRESH_INTERVAL_SECONDS)).toBe(MAX_REFRESH_INTERVAL_MS);
expect(normalizeRefreshInterval(MAX_REFRESH_INTERVAL_SECONDS + 1)).toBe(MAX_REFRESH_INTERVAL_MS);
expect(normalizeRefreshInterval(Number.MAX_VALUE)).toBe(MAX_REFRESH_INTERVAL_MS);
expect(MAX_REFRESH_INTERVAL_MS).toBeLessThan(2 ** 31 - 1);
});
it("routes binding-construction failures through the runtime error display", async () => {
await render(
<CustomJsxRenderer
template="<Text>Safe</Text>"
data={{}}
components={{ Text: (() => null) as never }}
createBindings={() => {
throw new RangeError("Response data exceeded the depth limit");
}}
messages={{ noTemplate: "No template", templateWarnings: (count) => `${count} warnings` }}
/>,
createPort(),
);
expect(host.textContent).toContain("RUNTIME_RENDER_ERROR");
expect(host.textContent).toContain("Response data exceeded the depth limit");
});
it("renders common safe collection and number formatting operations", async () => {
const components = createCustomJsxComponents({
TablerIcon: (() => null) as never,
@@ -162,9 +188,7 @@ describe("Custom Widget runtime ports", () => {
await render(
<>
<button name="shared" role="radio" aria-checked="false" type="button">
Outside
</button>
<input aria-label="Outside" name="shared" type="radio" />
<CustomJsxRenderer
template={
'<Radio.Group name="shared" defaultValue="inside"><Radio.Card value="inside"><Radio.Indicator /></Radio.Card><Radio.Card value="other"><Radio.Indicator /></Radio.Card></Radio.Group>'
@@ -179,7 +203,8 @@ describe("Custom Widget runtime ports", () => {
);
await settle();
const [outside, inside, other] = [...host.querySelectorAll<HTMLButtonElement>('button[role="radio"]')];
const outside = host.querySelector<HTMLInputElement>('input[type="radio"]');
const [inside, other] = [...host.querySelectorAll<HTMLButtonElement>('button[role="radio"]')];
expect(outside?.name).toBe("shared");
expect(inside?.name).toMatch(/^custom-widget-/u);
expect(inside?.name).not.toBe(outside?.name);
@@ -53,6 +53,27 @@ describe("lean Custom Widget schema", () => {
expect(customWidgetDefinitionSchema.parse(withoutOptions).options).toEqual({});
});
it("rejects source IDs that collide case-insensitively", () => {
const result = customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
sources: {
Api: { baseUrl: "https://one.example.com", networkScope: "public", auth: "none" },
api: { baseUrl: "https://two.example.com", networkScope: "public", auth: "none" },
},
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: ["sources", "api"],
message: expect.stringContaining("conflicts case-insensitively"),
}),
]),
);
}
});
it("rejects invocation params on load and unknown options", () => {
const loadParam = customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
@@ -7,7 +7,40 @@ import type { Root } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { PreviewHeader, PreviewResponsePanel, ResponseTree } from "../workbench";
import { CustomWidgetCodeEditor, PreviewHeader, PreviewResponsePanel, ResponseTree } from "../workbench";
import type { CustomWidgetEditorMessages } from "../workbench";
vi.mock("../workbench/direct-code-mirror", () => ({
default: ({ id, label, labelledBy }: { id: string; label: string; labelledBy: string }) => (
<textarea id={id} aria-label={label} aria-labelledby={labelledBy} />
),
}));
const editorMessages: CustomWidgetEditorMessages = {
languageJsx: "JSX",
languageJson: "JSON",
undo: "Undo",
redo: "Redo",
components: "Components",
componentSearch: "Search components",
componentEmpty: "No components",
componentCount: (count) => `${count} components`,
insertStarter: "Insert starter",
format: "Format",
copy: "Copy",
copied: "Copied",
schema: "Schema",
schemaTab: "JSON Schema",
minimalTab: "Minimal",
fullTab: "Full",
errors: (count) => `${count} errors`,
warnings: (count) => `${count} warnings`,
ready: "Ready",
position: ({ line, column }) => `${line}:${column}`,
characters: (count) => `${count}`,
diagnosticsTitle: "Diagnostics",
diagnostic: (diagnostic) => diagnostic.code,
};
let root: Root;
let host: HTMLDivElement;
@@ -39,6 +72,25 @@ async function render(node: ReactNode) {
}
describe("Custom Widget workbench UI", () => {
test("associates the visible label with the editable CodeMirror control", async () => {
await render(
<CustomWidgetCodeEditor
id="template-editor"
label="Template"
language="jsx"
value="<Text>Hello</Text>"
messages={editorMessages}
onChange={vi.fn()}
/>,
);
await act(async () => Promise.resolve());
const label = host.querySelector('#template-editor-label[for="template-editor"]');
expect(label).not.toBeNull();
expect(host.querySelector("#template-editor")?.getAttribute("aria-label")).toBe("Template");
expect(host.querySelector("#template-editor")?.getAttribute("aria-labelledby")).toBe("template-editor-label");
}, 15_000);
test("renders response data through the Mantine Tree with accessible path actions", async () => {
await render(
<ResponseTree