fix(custom-widgets): address v2 merge review (#6446)
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user