feat(custom-widgets): Full Mantine v9 + SubFetch support for custom JSX (#6304)

This commit is contained in:
Thomas authored and GitHub committed 2026-07-15 14:06:02 +02:00
1 parent 0a0b3a12c9
commit 3b2fc2527e
181 files changed
+18472 -4364

No files matched your search

@@ -0,0 +1,67 @@
import { describe, expect, test } from "vitest";
import {
appendPreviewJournal,
createPreviewSession,
getPreviewJournal,
getPreviewSession,
getPreviewSessionSecrets,
setPreviewSessionLiveActions,
} from "../../custom-widget/preview-sessions";
const queryRequest = {
id: "status",
kind: "query",
method: "GET",
pathTemplate: "/status/{id}",
parameters: { id: "string" },
auth: "inherit",
minimumBoardPermission: "view",
} as const;
describe("custom widget preview sessions", () => {
test("stores encrypted credentials in a short-lived user-bound manifest", async () => {
const created = await createPreviewSession({
userId: "admin-1",
baseUrl: "https://example.com/api",
authType: "bearer",
secrets: [{ kind: "apiKey", value: "plain-secret" }],
networkScope: "public",
requests: [queryRequest],
});
const session = await getPreviewSession(created.id, "admin-1");
expect(session.secrets[0]?.value).not.toContain("plain-secret");
expect(getPreviewSessionSecrets(session)).toEqual([{ kind: "apiKey", value: "plain-secret" }]);
await expect(getPreviewSession(created.id, "admin-2")).rejects.toMatchObject({ code: "NOT_FOUND" });
});
test("keeps actions simulated until explicitly enabled", async () => {
const created = await createPreviewSession({
userId: "admin-1",
baseUrl: "https://example.com/api",
authType: "none",
secrets: [],
networkScope: "public",
requests: [queryRequest],
});
expect((await getPreviewSession(created.id, "admin-1")).liveActions).toBe(false);
await setPreviewSessionLiveActions(created.id, "admin-1", true);
const session = await getPreviewSession(created.id, "admin-1");
expect(session.liveActions).toBe(true);
await appendPreviewJournal(session, {
requestId: "status",
kind: "query",
method: "GET",
pathTemplate: "/status/{id}",
status: 200,
durationMs: 12,
simulated: false,
});
expect(await getPreviewJournal(created.id, "admin-1")).toEqual([
expect.objectContaining({ requestId: "status", pathTemplate: "/status/{id}", durationMs: 12 }),
]);
});
});
@@ -0,0 +1,65 @@
import { describe, expect, test } from "vitest";
import {
assertJsonBudget,
assertSafeStaticHeaders,
classifyAddress,
resolveAndValidateHost,
resolveSameOriginTarget,
validateCustomWidgetUrl,
} from "../../custom-widget/request-executor";
describe("custom widget request executor", () => {
test.each([
["8.8.8.8", "public"],
["10.0.0.1", "private"],
["127.0.0.1", "loopback"],
["169.254.169.254", "blocked"],
["224.0.0.1", "blocked"],
["::ffff:127.0.0.1", "blocked"],
["fe80::1", "blocked"],
] as const)("classifies %s as %s", (address, expected) => {
expect(classifyAddress(address)).toBe(expected);
});
test("enforces configured address scopes", async () => {
await expect(resolveAndValidateHost("10.0.0.1", "public")).rejects.toMatchObject({ code: "FORBIDDEN" });
await expect(resolveAndValidateHost("10.0.0.1", "private")).resolves.toHaveLength(1);
await expect(resolveAndValidateHost("127.0.0.1", "private")).rejects.toMatchObject({ code: "FORBIDDEN" });
await expect(resolveAndValidateHost("127.0.0.1", "loopback")).resolves.toHaveLength(1);
await expect(resolveAndValidateHost("169.254.169.254", "loopback")).rejects.toMatchObject({
code: "FORBIDDEN",
});
});
test("allows only credential-free HTTP(S) URLs without fragments", () => {
expect(validateCustomWidgetUrl("https://example.com/api").href).toBe("https://example.com/api");
expect(() => validateCustomWidgetUrl("file:///etc/passwd")).toThrow("Only HTTP and HTTPS");
expect(() => validateCustomWidgetUrl("https://user:password@example.com/api")).toThrow("credentials");
expect(() => validateCustomWidgetUrl("https://example.com/api#secret")).toThrow("fragments");
});
test("rejects cross-origin named request targets", () => {
expect(resolveSameOriginTarget("https://example.com/base", "https://example.com/status").pathname).toBe("/status");
expect(() => resolveSameOriginTarget("https://example.com/base", "https://attacker.example/status")).toThrow(
"origin",
);
});
test.each(["Authorization", "Cookie", "Host", "Proxy-Authorization", "Sec-Fetch-Site", "X-Forwarded-For"])(
"rejects the reserved %s header",
(header) => {
expect(() => assertSafeStaticHeaders({ [header]: "value" })).toThrow("reserved");
},
);
test("rejects response JSON that exceeds the depth budget", () => {
let nested: Record<string, unknown> = {};
const root = nested;
for (let index = 0; index < 40; index += 1) {
nested.child = {};
nested = nested.child as Record<string, unknown>;
}
expect(() => assertJsonBudget(root)).toThrow("deeply nested");
});
});
@@ -0,0 +1,30 @@
import { expect, test } from "vitest";
import { createId } from "@homarr/common";
import { acquireCustomWidgetRequestLimit } from "../../custom-widget/request-limits";
test("enforces the server-side DELETE rate limit with retry metadata", async () => {
const input = { category: "delete" as const, userId: createId(), itemId: createId(), definitionId: createId() };
for (let count = 0; count < 3; count += 1) {
const release = await acquireCustomWidgetRequestLimit(input);
await release();
}
try {
await acquireCustomWidgetRequestLimit(input);
expect.unreachable("the fourth DELETE request should be rate limited");
} catch (error) {
expect(error).toMatchObject({ code: "TOO_MANY_REQUESTS" });
const cause = (error as { cause?: { flatten?: () => Record<string, unknown> } }).cause;
expect(cause?.flatten?.()).toMatchObject({ retryAfterMs: expect.any(Number) });
}
});
test("enforces four concurrent requests per user and item", async () => {
const input = { category: "query" as const, userId: createId(), itemId: createId(), definitionId: createId() };
const releases = await Promise.all(Array.from({ length: 4 }, () => acquireCustomWidgetRequestLimit(input)));
await expect(acquireCustomWidgetRequestLimit(input)).rejects.toMatchObject({ code: "TOO_MANY_REQUESTS" });
await Promise.all(releases.map((release) => release()));
});
@@ -0,0 +1,224 @@
import { stringify as stringifySuperJson } from "superjson";
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { eq } from "@homarr/db";
import { boardUserPermissions, boards, customWidgetDefinitions, items, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
const mocks = vi.hoisted(() => ({
executeRequest: vi.fn(async () => ({ ok: true, status: 200, statusText: "OK", data: { value: 42 } })),
}));
vi.mock("../../custom-widget/request-executor", () => ({
executeCustomWidgetRequest: mocks.executeRequest,
resolveSameOriginTarget: (_baseUrl: string, targetUrl: string | URL) => new URL(targetUrl),
}));
vi.mock("../../custom-widget/request-limits", () => ({
acquireCustomWidgetRequestLimit: vi.fn(async () => async () => undefined),
}));
import { customApiRouter } from "../../widgets/custom-api";
const createSession = (userId: string): Session => ({
user: { id: userId, permissions: [], colorScheme: "light" },
expires: new Date(Date.now() + 60_000).toISOString(),
});
const queryRequest = {
id: "status",
kind: "query",
method: "GET",
pathTemplate: "/status/{name}",
parameters: { name: "string" },
auth: "inherit",
minimumBoardPermission: "view",
} as const;
const actionRequest = {
id: "toggle",
kind: "action",
method: "POST",
pathTemplate: "/toggle",
parameters: { enabled: "boolean" },
bodyTemplate: { enabled: { $param: "enabled" } },
auth: "inherit",
minimumBoardPermission: "modify",
} as const;
const deleteRequest = {
id: "delete",
kind: "action",
method: "DELETE",
pathTemplate: "/resource",
parameters: {},
auth: "inherit",
minimumBoardPermission: "full",
} as const;
const setup = async ({
isPublic = true,
method = "GET",
requests = [queryRequest, actionRequest, deleteRequest],
}: {
isPublic?: boolean;
method?: "GET" | "POST";
requests?: readonly Record<string, unknown>[];
} = {}) => {
const db = createDb();
const ownerId = createId();
const boardId = createId();
const itemId = createId();
const definitionId = createId();
await db.insert(users).values({ id: ownerId });
await db.insert(boards).values({ id: boardId, name: createId(), creatorId: ownerId, isPublic });
await db.insert(customWidgetDefinitions).values({
id: definitionId,
name: "Custom JSX",
url: "https://example.com/api",
method,
authType: "none",
displayType: "customJsx",
displayConfig: stringifySuperJson({
type: "customJsx",
jsxApiVersion: 2,
template: "<Text>{data.value}</Text>",
networkScope: "public",
requests,
}),
creatorId: ownerId,
});
await db.insert(items).values({
id: itemId,
boardId,
kind: "customApi",
options: stringifySuperJson({ definitionId, refreshInterval: 30 }),
});
return { db, ownerId, boardId, itemId, definitionId };
};
describe("customApiRouter v2", () => {
beforeEach(() => mocks.executeRequest.mockClear());
test("does not expose the removed raw subFetch procedure", () => {
expect(Object.hasOwn(customApiRouter._def.procedures, "subFetch")).toBe(false);
});
test("allows anonymous queries only through a public board placement", async () => {
const { db, itemId } = await setup();
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
const result = await caller.queryRequest({ itemId, requestId: "status", params: { name: "homarr" } });
expect(result).toMatchObject({ ok: true, status: 200, data: { value: 42 } });
expect(mocks.executeRequest).toHaveBeenCalledOnce();
});
test("does not expose a placed definition through an inaccessible board", async () => {
const { db, itemId } = await setup({ isPublic: false });
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
await expect(caller.getData({ itemId })).rejects.toMatchObject({ code: "NOT_FOUND" });
expect(mocks.executeRequest).not.toHaveBeenCalled();
});
test("does not execute non-GET base data requests automatically", async () => {
const { db, itemId } = await setup({ method: "POST" });
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
await expect(caller.getData({ itemId })).resolves.toEqual({ type: "networkAccessNeedsReview" });
expect(mocks.executeRequest).not.toHaveBeenCalled();
});
test("cannot select a request from a definition that is not attached to the item", async () => {
const { db, itemId, ownerId } = await setup({ requests: [] });
const otherDefinitionId = createId();
await db.insert(customWidgetDefinitions).values({
id: otherDefinitionId,
name: "Other",
url: "https://example.com/api",
method: "GET",
authType: "none",
displayType: "customJsx",
displayConfig: stringifySuperJson({
type: "customJsx",
jsxApiVersion: 2,
template: "<Text />",
networkScope: "public",
requests: [queryRequest],
}),
creatorId: ownerId,
});
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
await expect(
caller.queryRequest({ itemId, requestId: "status", params: { name: "homarr" } }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
expect(mocks.executeRequest).not.toHaveBeenCalled();
});
test("rejects mismatched request kinds and typed parameters", async () => {
const { db, itemId } = await setup();
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
await expect(caller.queryRequest({ itemId, requestId: "toggle", params: { enabled: true } })).rejects.toMatchObject(
{ code: "NOT_FOUND" },
);
await expect(caller.queryRequest({ itemId, requestId: "status", params: { name: 42 } })).rejects.toMatchObject({
code: "BAD_REQUEST",
});
expect(mocks.executeRequest).not.toHaveBeenCalled();
});
test("rejects disabled definitions before any network request", async () => {
const { db, itemId, definitionId } = await setup();
await db
.update(customWidgetDefinitions)
.set({ enabled: false })
.where(eq(customWidgetDefinitions.id, definitionId));
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
await expect(caller.getData({ itemId })).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(mocks.executeRequest).not.toHaveBeenCalled();
});
test("requires modify permission for named actions", async () => {
const { db, itemId } = await setup();
const viewerId = createId();
await db.insert(users).values({ id: viewerId });
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(viewerId) });
await expect(
caller.executeAction({ itemId, requestId: "toggle", params: { enabled: true } }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
expect(mocks.executeRequest).not.toHaveBeenCalled();
});
test("executes an authorized named action once with typed body substitution", async () => {
const { db, itemId, boardId } = await setup({ isPublic: false });
const editorId = createId();
await db.insert(users).values({ id: editorId });
await db.insert(boardUserPermissions).values({ boardId, userId: editorId, permission: "modify" });
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(editorId) });
const result = await caller.executeAction({ itemId, requestId: "toggle", params: { enabled: true } });
expect(result.ok).toBe(true);
expect(mocks.executeRequest).toHaveBeenCalledOnce();
expect(mocks.executeRequest).toHaveBeenCalledWith(
expect.objectContaining({ method: "POST", body: '{"enabled":true}' }),
);
});
test("requires explicit confirmation before DELETE", async () => {
const { db, itemId, ownerId } = await setup({ isPublic: false });
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(ownerId) });
await expect(caller.executeAction({ itemId, requestId: "delete", params: {} })).rejects.toMatchObject({
code: "PRECONDITION_FAILED",
});
expect(mocks.executeRequest).not.toHaveBeenCalled();
});
});