feat(custom-widgets): complete Custom JSX v2 (#6390)
This commit is contained in:
1 parent
3a1245cbc5
commit
acdfcbb18e
416 files changed
+37853
-18066
No files matched your search
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
claimCustomWidgetConfigurationRequest,
|
||||
completeCustomWidgetConfigurationRequest,
|
||||
createCustomWidgetConfigurationRequest,
|
||||
getCustomWidgetConfigurationRequestForUser,
|
||||
releaseCustomWidgetConfigurationRequest,
|
||||
} from "../../custom-widget/configuration-requests";
|
||||
|
||||
describe("custom widget source configuration requests", () => {
|
||||
it("is user-scoped, one-time, and reports completion without returning configuration", async () => {
|
||||
const request = await createCustomWidgetConfigurationRequest({
|
||||
userId: "user-1",
|
||||
target: { type: "preview", id: "preview-1" },
|
||||
widgetName: "Printer",
|
||||
sourceId: "default",
|
||||
sourceName: "Printer API",
|
||||
source: { baseUrl: "http://printer.local", networkScope: "private", auth: "bearer" },
|
||||
kinds: ["apiKey"],
|
||||
});
|
||||
|
||||
expect(await getCustomWidgetConfigurationRequestForUser(request.id, "user-2")).toBeNull();
|
||||
expect(await claimCustomWidgetConfigurationRequest(request.id)).toMatchObject({ status: "pending" });
|
||||
expect(await claimCustomWidgetConfigurationRequest(request.id)).toBeNull();
|
||||
await releaseCustomWidgetConfigurationRequest(request.id);
|
||||
expect(await claimCustomWidgetConfigurationRequest(request.id)).not.toBeNull();
|
||||
await completeCustomWidgetConfigurationRequest(request.id);
|
||||
expect(await getCustomWidgetConfigurationRequestForUser(request.id, "user-1")).toMatchObject({
|
||||
status: "completed",
|
||||
});
|
||||
expect(await claimCustomWidgetConfigurationRequest(request.id)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { eq, sql } from "@homarr/db";
|
||||
import { customWidgetDefinitions, customWidgetSecrets, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import { BUNDLED_CUSTOM_WIDGETS } from "@homarr/custom-widgets/core";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { customWidgetRouter } from "../../custom-widget/custom-widget-router";
|
||||
import { getCustomWidgetConfigurationRequestForUser } from "../../custom-widget/configuration-requests";
|
||||
|
||||
const userId = createId();
|
||||
const session = {
|
||||
user: {
|
||||
id: userId,
|
||||
permissions: ["custom-widget-manage", "custom-widget-secret-write"],
|
||||
colorScheme: "light",
|
||||
},
|
||||
expires: new Date().toISOString(),
|
||||
} satisfies Session;
|
||||
|
||||
const jellyfin = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-jellyfin")?.widget;
|
||||
if (!jellyfin) throw new Error("Jellyfin bundled widget is missing");
|
||||
|
||||
function createCaller(db: ReturnType<typeof createDb>) {
|
||||
return customWidgetRouter.createCaller({ db, deviceType: undefined, session });
|
||||
}
|
||||
|
||||
async function prepareDatabase() {
|
||||
const db = createDb();
|
||||
await db.insert(users).values({ id: userId });
|
||||
return db;
|
||||
}
|
||||
|
||||
function rejectSecretInserts(db: ReturnType<typeof createDb>) {
|
||||
db.run(
|
||||
sql.raw(`CREATE TRIGGER reject_custom_widget_secret_insert
|
||||
BEFORE INSERT ON custom_widget_secret
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced secret insert failure');
|
||||
END`),
|
||||
);
|
||||
}
|
||||
|
||||
const secret = { sourceId: "default", kind: "apiKey" as const, value: "test-secret" };
|
||||
|
||||
describe("custom widget definition persistence", () => {
|
||||
test("rolls back create when its secret cannot be stored", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
rejectSecretInserts(db);
|
||||
|
||||
await expect(caller.create({ ...jellyfin, secrets: [secret] })).rejects.toThrow("forced secret insert failure");
|
||||
|
||||
expect(await db.query.customWidgetDefinitions.findMany()).toHaveLength(0);
|
||||
expect(await db.query.customWidgetSecrets.findMany()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("rolls back import when its secret cannot be stored", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
rejectSecretInserts(db);
|
||||
|
||||
await expect(caller.import({ widget: jellyfin, secrets: [secret] })).rejects.toThrow(
|
||||
"forced secret insert failure",
|
||||
);
|
||||
|
||||
expect(await db.query.customWidgetDefinitions.findMany()).toHaveLength(0);
|
||||
expect(await db.query.customWidgetSecrets.findMany()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("rolls back definition and secret changes when a replacement secret cannot be stored", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const created = await caller.create({ ...jellyfin, secrets: [secret] });
|
||||
const originalDefinition = await db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, created.id),
|
||||
});
|
||||
const originalSecret = await db.query.customWidgetSecrets.findFirst({
|
||||
where: eq(customWidgetSecrets.definitionId, created.id),
|
||||
});
|
||||
rejectSecretInserts(db);
|
||||
|
||||
await expect(
|
||||
caller.update({
|
||||
id: created.id,
|
||||
name: "Changed Jellyfin Library",
|
||||
secrets: [{ ...secret, value: "replacement-secret" }],
|
||||
}),
|
||||
).rejects.toThrow("forced secret insert failure");
|
||||
|
||||
const storedDefinition = await db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, created.id),
|
||||
});
|
||||
const storedSecret = await db.query.customWidgetSecrets.findFirst({
|
||||
where: eq(customWidgetSecrets.definitionId, created.id),
|
||||
});
|
||||
expect(storedDefinition?.name).toBe(originalDefinition?.name);
|
||||
expect(storedDefinition?.updatedAt).toEqual(originalDefinition?.updatedAt);
|
||||
expect(storedSecret).toEqual(originalSecret);
|
||||
});
|
||||
|
||||
test("configures a source URL and credential together", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const created = await caller.create({ ...jellyfin, secrets: [] });
|
||||
|
||||
await caller.sourceConfigure({
|
||||
definitionId: created.id,
|
||||
sourceId: "default",
|
||||
baseUrl: "http://jellyfin.local:8096",
|
||||
networkScope: "private",
|
||||
secrets: [secret],
|
||||
});
|
||||
|
||||
const definition = await caller.get({ id: created.id });
|
||||
expect(definition.sources.default?.baseUrl).toBe("http://jellyfin.local:8096");
|
||||
expect(definition.secrets).toMatchObject([{ sourceId: "default", kind: "apiKey", hasValue: true }]);
|
||||
});
|
||||
|
||||
test("rolls back a source URL when its credential cannot be stored", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const created = await caller.create({ ...jellyfin, secrets: [] });
|
||||
const before = await caller.get({ id: created.id });
|
||||
rejectSecretInserts(db);
|
||||
|
||||
await expect(
|
||||
caller.sourceConfigure({
|
||||
definitionId: created.id,
|
||||
sourceId: "default",
|
||||
baseUrl: "http://jellyfin.local:8096",
|
||||
networkScope: "private",
|
||||
secrets: [secret],
|
||||
}),
|
||||
).rejects.toThrow("forced secret insert failure");
|
||||
|
||||
expect((await caller.get({ id: created.id })).sources.default?.baseUrl).toBe(before.sources.default?.baseUrl);
|
||||
});
|
||||
|
||||
test("uses the preview widget name in a user configuration request", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const preview = await caller.previewCreate({
|
||||
definition: { ...jellyfin, name: "Living room Jellyfin" },
|
||||
secrets: [],
|
||||
});
|
||||
|
||||
const request = await caller.configurationRequestUser({
|
||||
previewSessionId: preview.previewSession.id,
|
||||
sourceId: "default",
|
||||
});
|
||||
|
||||
expect(await getCustomWidgetConfigurationRequestForUser(request.requestId, userId)).toMatchObject({
|
||||
widgetName: "Living room Jellyfin",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { resolvePreviewRequestParams } from "../../custom-widget/preview-procedure-helpers";
|
||||
|
||||
const loadRequest: CustomJsxRequest = {
|
||||
source: "default",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
path: "/endpoints/{option:environmentId}/containers",
|
||||
auth: "inherit",
|
||||
permission: "view",
|
||||
trigger: "load",
|
||||
};
|
||||
|
||||
describe("preview request option bindings", () => {
|
||||
it("resolves load request parameters from the selected preview options", () => {
|
||||
expect(resolvePreviewRequestParams(loadRequest, { environmentId: 7 }, {})).toEqual({
|
||||
"option:environmentId": 7,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves invoking component parameters for manual requests", () => {
|
||||
const manualRequest = {
|
||||
...loadRequest,
|
||||
trigger: "manual" as const,
|
||||
path: "/endpoints/{param:endpointId}/containers",
|
||||
};
|
||||
expect(resolvePreviewRequestParams(manualRequest, { environmentId: 7 }, { endpointId: 12 })).toEqual({
|
||||
endpointId: 12,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
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,48 @@
|
||||
import { stringify as stringifySuperJson } from "superjson";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
BUNDLED_CUSTOM_WIDGETS,
|
||||
CUSTOM_WIDGET_STARTER,
|
||||
customWidgetDefinitionSchema,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
|
||||
import {
|
||||
safeParseStoredCustomWidgetDefinition,
|
||||
serializeCustomWidgetDefinition,
|
||||
} from "../../custom-widget/stored-definition";
|
||||
|
||||
describe("stored custom widget definitions", () => {
|
||||
test("parses a valid stored definition", () => {
|
||||
const result = safeParseStoredCustomWidgetDefinition(
|
||||
serializeCustomWidgetDefinition(customWidgetDefinitionSchema.parse(CUSTOM_WIDGET_STARTER)),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ success: true, widget: { $schema: "homarr-custom-widget-v2" } });
|
||||
});
|
||||
|
||||
test("returns useful issues for an invalid stored request instead of throwing", () => {
|
||||
const currency = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-currency-exchange");
|
||||
if (!currency) throw new Error("Currency Exchange bundled widget is missing");
|
||||
|
||||
const stored = serializeCustomWidgetDefinition(customWidgetDefinitionSchema.parse(currency.widget));
|
||||
const result = safeParseStoredCustomWidgetDefinition({
|
||||
...stored,
|
||||
requests: stringifySuperJson({ rates: { path: "/latest/{option:missing}" } }),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
issues: expect.arrayContaining([expect.objectContaining({ path: "requests.rates" })]),
|
||||
});
|
||||
});
|
||||
|
||||
test("contains malformed serialized data", () => {
|
||||
const stored = serializeCustomWidgetDefinition(customWidgetDefinitionSchema.parse(CUSTOM_WIDGET_STARTER));
|
||||
|
||||
expect(safeParseStoredCustomWidgetDefinition({ ...stored, requests: "not-json" })).toEqual({
|
||||
success: false,
|
||||
issues: [{ message: "Stored widget data could not be read" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
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";
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
|
||||
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,
|
||||
invalidateCustomWidgetResponseCache: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../custom-widget/request-limits", () => ({
|
||||
acquireCustomWidgetRequestLimit: vi.fn(async () => async () => undefined),
|
||||
}));
|
||||
|
||||
import { serializeCustomWidgetDefinition } from "../../custom-widget/stored-definition";
|
||||
import { customApiRouter } from "../../widgets/custom-api";
|
||||
|
||||
const definition: HomarrCustomWidgetV2 = {
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: "Router test widget",
|
||||
sources: {
|
||||
default: {
|
||||
name: "API",
|
||||
baseUrl: "https://example.com",
|
||||
networkScope: "public",
|
||||
auth: "none",
|
||||
},
|
||||
},
|
||||
requests: {
|
||||
"load-status": {
|
||||
source: "default",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
path: "/status",
|
||||
auth: "inherit",
|
||||
permission: "view",
|
||||
trigger: "load",
|
||||
},
|
||||
status: {
|
||||
source: "default",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
path: "/status/{param:name}",
|
||||
auth: "inherit",
|
||||
permission: "view",
|
||||
trigger: "manual",
|
||||
},
|
||||
toggle: {
|
||||
source: "default",
|
||||
kind: "action",
|
||||
method: "POST",
|
||||
path: "/toggle",
|
||||
body: { enabled: { $param: "enabled" } },
|
||||
auth: "inherit",
|
||||
permission: "modify",
|
||||
trigger: "manual",
|
||||
},
|
||||
delete: {
|
||||
source: "default",
|
||||
kind: "action",
|
||||
method: "DELETE",
|
||||
path: "/resource",
|
||||
auth: "inherit",
|
||||
permission: "full",
|
||||
trigger: "manual",
|
||||
confirmation: { title: "Delete resource", message: "Delete this resource?", destructive: true },
|
||||
},
|
||||
},
|
||||
options: {},
|
||||
template: "<Text>{data['load-status']?.value}</Text>",
|
||||
};
|
||||
|
||||
const createSession = (userId: string): Session => ({
|
||||
user: { id: userId, permissions: [], colorScheme: "light" },
|
||||
expires: new Date(Date.now() + 60_000).toISOString(),
|
||||
});
|
||||
|
||||
async function setup(isPublic = true) {
|
||||
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,
|
||||
...serializeCustomWidgetDefinition(definition),
|
||||
creatorId: ownerId,
|
||||
});
|
||||
await db.insert(items).values({
|
||||
id: itemId,
|
||||
boardId,
|
||||
kind: "customApi",
|
||||
options: stringifySuperJson({ definitionId, configuration: {}, configurationVersion: 1 }),
|
||||
});
|
||||
return { db, ownerId, boardId, itemId, definitionId };
|
||||
}
|
||||
|
||||
describe("Custom JSX v2 board router", () => {
|
||||
beforeEach(() => mocks.executeRequest.mockClear());
|
||||
|
||||
test("allows public queries only through the definition placed on an accessible board", async () => {
|
||||
const { db, itemId } = await setup();
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
await expect(caller.queryRequest({ itemId, requestId: "status", params: { name: "homarr" } })).resolves.toEqual(
|
||||
expect.objectContaining({ ok: true, data: { value: 42 } }),
|
||||
);
|
||||
await expect(caller.queryRequest({ itemId, requestId: "missing", params: {} })).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
expect(mocks.executeRequest).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("hides private boards and disabled definitions before executing a request", async () => {
|
||||
const privateSetup = await setup(false);
|
||||
const anonymous = customApiRouter.createCaller({
|
||||
db: privateSetup.db,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
});
|
||||
await expect(
|
||||
anonymous.queryRequest({ itemId: privateSetup.itemId, requestId: "status", params: { name: "homarr" } }),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
|
||||
const enabledSetup = await setup();
|
||||
await enabledSetup.db
|
||||
.update(customWidgetDefinitions)
|
||||
.set({ enabled: false })
|
||||
.where(eq(customWidgetDefinitions.id, enabledSetup.definitionId));
|
||||
const publicCaller = customApiRouter.createCaller({ db: enabledSetup.db, deviceType: undefined, session: null });
|
||||
await expect(publicCaller.getData({ itemId: enabledSetup.itemId })).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
await expect(
|
||||
publicCaller.queryRequest({ itemId: enabledSetup.itemId, requestId: "status", params: { name: "homarr" } }),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
const ownerCaller = customApiRouter.createCaller({
|
||||
db: enabledSetup.db,
|
||||
deviceType: undefined,
|
||||
session: createSession(enabledSetup.ownerId),
|
||||
});
|
||||
await expect(
|
||||
ownerCaller.executeAction({ itemId: enabledSetup.itemId, requestId: "toggle", params: { enabled: true } }),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects request-kind and parameter mismatches", async () => {
|
||||
const { db, itemId, ownerId } = await setup();
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(ownerId) });
|
||||
|
||||
await expect(caller.queryRequest({ itemId, requestId: "toggle", params: { enabled: true } })).rejects.toMatchObject(
|
||||
{ code: "NOT_FOUND" },
|
||||
);
|
||||
await expect(caller.queryRequest({ itemId, requestId: "status", params: {} })).rejects.toThrow();
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("enforces modify permission and renders an authorized action body", async () => {
|
||||
const { db, itemId, boardId } = await setup(false);
|
||||
const editorId = createId();
|
||||
await db.insert(users).values({ id: editorId });
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(editorId) });
|
||||
|
||||
await expect(
|
||||
caller.executeAction({ itemId, requestId: "toggle", params: { enabled: true } }),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
await db.insert(boardUserPermissions).values({ boardId, userId: editorId, permission: "modify" });
|
||||
await expect(caller.executeAction({ itemId, requestId: "toggle", params: { enabled: true } })).resolves.toEqual(
|
||||
expect.objectContaining({ ok: true }),
|
||||
);
|
||||
expect(mocks.executeRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: "POST", body: '{"enabled":true}' }),
|
||||
);
|
||||
});
|
||||
|
||||
test("requires confirmation and full permission for DELETE actions", async () => {
|
||||
const { db, itemId, boardId, ownerId } = await setup(false);
|
||||
const owner = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(ownerId) });
|
||||
await expect(owner.executeAction({ itemId, requestId: "delete", params: {} })).rejects.toMatchObject({
|
||||
code: "PRECONDITION_FAILED",
|
||||
});
|
||||
|
||||
const editorId = createId();
|
||||
await db.insert(users).values({ id: editorId });
|
||||
await db.insert(boardUserPermissions).values({ boardId, userId: editorId, permission: "modify" });
|
||||
const editor = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(editorId) });
|
||||
await expect(
|
||||
editor.executeAction({ itemId, requestId: "delete", params: {}, confirmed: true }),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
await expect(owner.executeAction({ itemId, requestId: "delete", params: {}, confirmed: true })).resolves.toEqual(
|
||||
expect.objectContaining({ ok: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,224 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user