merge: stack widget modernization on dnd kit
This commit is contained in:
940 files changed
+104475
-33649
No files matched your search
@@ -1,49 +1,225 @@
|
||||
import { expect, test, vi } from "vitest";
|
||||
import type { TRPCError } from "@trpc/server";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { extractToolsFromProcedures } from "trpc-to-mcp";
|
||||
|
||||
import { appRouter } from "../router/app";
|
||||
import { boardRouter } from "../router/board";
|
||||
import { infoRouter } from "../router/info";
|
||||
import { inviteRouter } from "../router/invite";
|
||||
import { serverSettingsRouter } from "../router/serverSettings";
|
||||
import { createTRPCRouter } from "../trpc";
|
||||
import { mcpRouter } from "../mcp";
|
||||
|
||||
vi.mock("@homarr/auth", () => ({}));
|
||||
|
||||
const mcpTestRouter = createTRPCRouter({
|
||||
app: appRouter,
|
||||
board: boardRouter,
|
||||
info: infoRouter,
|
||||
invite: inviteRouter,
|
||||
serverSettings: serverSettingsRouter,
|
||||
const MCP_TOOL_ALLOWLIST = {
|
||||
query: [
|
||||
"apiKeys_getAll",
|
||||
"app_all",
|
||||
"app_byId",
|
||||
"app_getPaginated",
|
||||
"app_search",
|
||||
"bazarr_getBadges",
|
||||
"beszel_getAlerts",
|
||||
"beszel_getSystems",
|
||||
"beszel_getSystemStats",
|
||||
"board_getAllBoards",
|
||||
"calendar_findAllEvents",
|
||||
"customWidget_get",
|
||||
"customWidget_getAuthoringPrompt",
|
||||
"customWidget_getSkill",
|
||||
"customWidget_legacyMigrationPrompt",
|
||||
"customWidget_list",
|
||||
"customWidget_previewJournal",
|
||||
"customWidget_previewQuery",
|
||||
"customWidget_schema",
|
||||
"customWidget_validate",
|
||||
"customWidget_workshopGet",
|
||||
"customWidget_workshopSearch",
|
||||
"dnsHole_summary",
|
||||
"docker_getContainers",
|
||||
"docker_logs",
|
||||
"downloads_getJobsAndStatuses",
|
||||
"healthMonitoring_getClusterHealthStatus",
|
||||
"healthMonitoring_getSystemHealthStatus",
|
||||
"healthMonitoring_listStorageVolumes",
|
||||
"icon_findIcons",
|
||||
"info_getInfo",
|
||||
"integration_all",
|
||||
"integration_byId",
|
||||
"integration_getKinds",
|
||||
"integration_getMediaRequestOptions",
|
||||
"integration_mediaRequestSearchTargets",
|
||||
"integration_search",
|
||||
"integration_searchInIntegration",
|
||||
"integration_searchMediaRequests",
|
||||
"invite_getAll",
|
||||
"mediaRequests_getLatestRequests",
|
||||
"mediaRequests_getStats",
|
||||
"mediaServer_getCurrentStreams",
|
||||
"patchmon_getStats",
|
||||
"serverSettings_getBoardSettings",
|
||||
"smartHome_entityState",
|
||||
"widgetSecrets_getConfiguredKinds",
|
||||
],
|
||||
mutation: [
|
||||
"app_create",
|
||||
"app_delete",
|
||||
"app_update",
|
||||
"board_addItem",
|
||||
"board_changeBoardVisibility",
|
||||
"board_createBoard",
|
||||
"board_deleteBoard",
|
||||
"board_duplicateBoard",
|
||||
"board_renameBoard",
|
||||
"board_savePartialBoardSettings",
|
||||
"board_setHomeBoard",
|
||||
"board_setMobileHomeBoard",
|
||||
"customWidget_delete",
|
||||
"customWidget_previewAction",
|
||||
"customWidget_templatePatch",
|
||||
"dnsHole_disable",
|
||||
"dnsHole_enable",
|
||||
"docker_removeAll",
|
||||
"docker_restartAll",
|
||||
"docker_startAll",
|
||||
"docker_stopAll",
|
||||
"downloads_pause",
|
||||
"downloads_resume",
|
||||
"integration_create",
|
||||
"integration_delete",
|
||||
"integration_requestMedia",
|
||||
"invite_createInvite",
|
||||
"invite_deleteInvite",
|
||||
"mediaRequests_answerRequest",
|
||||
"serverSettings_updateBoardSettings",
|
||||
"smartHome_executeAutomation",
|
||||
"smartHome_switchEntity",
|
||||
],
|
||||
secret: [
|
||||
"apiKeys_create",
|
||||
"apiKeys_delete",
|
||||
"customWidget_configurationRequestUser",
|
||||
"customWidget_create",
|
||||
"customWidget_migrateLegacy",
|
||||
"customWidget_previewCreate",
|
||||
"customWidget_secretSet",
|
||||
"customWidget_sourceConfigure",
|
||||
"customWidget_update",
|
||||
"customWidget_workshopInstall",
|
||||
"widgetSecrets_deleteSecret",
|
||||
"widgetSecrets_setSecret",
|
||||
],
|
||||
} as const;
|
||||
|
||||
type ToolClassification = keyof typeof MCP_TOOL_ALLOWLIST;
|
||||
|
||||
function expectedToolInventory() {
|
||||
return Object.entries(MCP_TOOL_ALLOWLIST)
|
||||
.flatMap(([classification, names]) =>
|
||||
names.map((name) => ({ name, classification: classification as ToolClassification })),
|
||||
)
|
||||
.toSorted((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
function actualToolInventory() {
|
||||
const secretTools = new Set<string>(MCP_TOOL_ALLOWLIST.secret);
|
||||
const procedures = mcpRouter["_def"].procedures as unknown as Record<
|
||||
string,
|
||||
{ _def?: { type?: "query" | "mutation" } }
|
||||
>;
|
||||
return extractToolsFromProcedures(mcpRouter)
|
||||
.map((tool) => {
|
||||
const procedure = procedures[tool.pathInRouter.join(".")];
|
||||
const type = procedure?.["_def"]?.type;
|
||||
if (type !== "query" && type !== "mutation") throw new Error(`Unable to classify MCP tool '${tool.name}'`);
|
||||
return {
|
||||
name: tool.name,
|
||||
classification: secretTools.has(tool.name) ? ("secret" as const) : type,
|
||||
};
|
||||
})
|
||||
.toSorted((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
describe("production MCP router", () => {
|
||||
test("matches the reviewed tool and permission inventory exactly", () => {
|
||||
expect(actualToolInventory()).toEqual(expectedToolInventory());
|
||||
});
|
||||
|
||||
test("keeps every secret-bearing tool behind a mutation procedure", () => {
|
||||
const tools = extractToolsFromProcedures(mcpRouter);
|
||||
const procedures = mcpRouter["_def"].procedures as unknown as Record<
|
||||
string,
|
||||
{ _def?: { type?: "query" | "mutation" } }
|
||||
>;
|
||||
for (const name of MCP_TOOL_ALLOWLIST.secret) {
|
||||
const tool = tools.find((candidate) => candidate.name === name);
|
||||
expect(tool, `Missing reviewed secret tool '${name}'`).toBeDefined();
|
||||
const procedure = tool ? procedures[tool.pathInRouter.join(".")] : undefined;
|
||||
expect(procedure?.["_def"]?.type, `Secret tool '${name}' must remain a mutation`).toBe("mutation");
|
||||
}
|
||||
});
|
||||
|
||||
test("gives every production tool a description", () => {
|
||||
const tools = extractToolsFromProcedures(mcpRouter);
|
||||
for (const tool of tools) expect(tool.description, `Tool ${tool.name} should have a description`).toBeTruthy();
|
||||
expect(tools.find((tool) => tool.name === "customWidget_getAuthoringPrompt")?.description).toBe(
|
||||
"Get the current Custom Widget authoring instructions.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("MCP tools should be extractable from the router", () => {
|
||||
const act = () => extractToolsFromProcedures(mcpTestRouter);
|
||||
describe("custom widget authoring procedure access", () => {
|
||||
const unauthenticatedCaller = mcpRouter.createCaller({
|
||||
db: null as never,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
});
|
||||
|
||||
expect(act).not.toThrow();
|
||||
});
|
||||
|
||||
test("MCP tools should contain expected procedures", () => {
|
||||
const tools = extractToolsFromProcedures(mcpTestRouter);
|
||||
const toolNames = tools.map((tool) => tool.name);
|
||||
|
||||
expect(tools.length).toBeGreaterThan(0);
|
||||
expect(toolNames).toContain("app_all");
|
||||
expect(toolNames).toContain("app_byId");
|
||||
expect(toolNames).toContain("app_create");
|
||||
expect(toolNames).toContain("board_savePartialBoardSettings");
|
||||
expect(toolNames).toContain("board_duplicateBoard");
|
||||
expect(toolNames).toContain("info_getInfo");
|
||||
expect(toolNames).toContain("invite_getAll");
|
||||
expect(toolNames).toContain("serverSettings_getBoardSettings");
|
||||
expect(toolNames).toContain("serverSettings_updateBoardSettings");
|
||||
});
|
||||
|
||||
test("MCP tools should have descriptions", () => {
|
||||
const tools = extractToolsFromProcedures(mcpTestRouter);
|
||||
|
||||
for (const tool of tools) {
|
||||
expect(tool.description, `Tool ${tool.name} should have a description`).toBeTruthy();
|
||||
}
|
||||
test("rejects every authoring procedure without a session", async () => {
|
||||
const calls = [
|
||||
unauthenticatedCaller.customWidget.schema(),
|
||||
unauthenticatedCaller.customWidget.getAuthoringPrompt(),
|
||||
unauthenticatedCaller.customWidget.getSkill(),
|
||||
unauthenticatedCaller.customWidget.list(),
|
||||
unauthenticatedCaller.customWidget.validate({ widget: {} }),
|
||||
unauthenticatedCaller.customWidget.secretSet({
|
||||
definitionId: "widget",
|
||||
secret: { sourceId: "default", kind: "apiKey", value: "secret" },
|
||||
}),
|
||||
];
|
||||
for (const call of calls) {
|
||||
await expect(call).rejects.toEqual(expect.objectContaining<Partial<TRPCError>>({ code: "UNAUTHORIZED" }));
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects authoring and secret tools for authenticated non-admins", async () => {
|
||||
const nonAdminCaller = mcpRouter.createCaller({
|
||||
db: null as never,
|
||||
deviceType: undefined,
|
||||
session: { user: { id: "user-1", permissions: ["board-modify-all"] } } as never,
|
||||
});
|
||||
const calls = [
|
||||
nonAdminCaller.customWidget.schema(),
|
||||
nonAdminCaller.customWidget.getAuthoringPrompt(),
|
||||
nonAdminCaller.customWidget.getSkill(),
|
||||
nonAdminCaller.customWidget.secretSet({
|
||||
definitionId: "widget",
|
||||
secret: { sourceId: "default", kind: "apiKey", value: "secret" },
|
||||
}),
|
||||
];
|
||||
for (const call of calls) {
|
||||
await expect(call).rejects.toEqual(expect.objectContaining<Partial<TRPCError>>({ code: "FORBIDDEN" }));
|
||||
}
|
||||
});
|
||||
|
||||
test("allows admins to read authoring resources", async () => {
|
||||
const adminCaller = mcpRouter.createCaller({
|
||||
db: null as never,
|
||||
deviceType: undefined,
|
||||
session: { user: { id: "admin-1", permissions: ["admin"] } } as never,
|
||||
});
|
||||
await expect(adminCaller.customWidget.schema()).resolves.toMatchObject({ type: "object" });
|
||||
await expect(adminCaller.customWidget.getAuthoringPrompt()).resolves.toMatchObject({
|
||||
version: 2,
|
||||
prompt: expect.any(String),
|
||||
});
|
||||
await expect(adminCaller.customWidget.getSkill()).resolves.toMatchObject({
|
||||
name: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,65 +1,20 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { boards, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import {
|
||||
isPersistableWidgetQueryKey,
|
||||
dashboardSupportingQueryPolicies,
|
||||
isWidgetDataQueryKey,
|
||||
isWidgetDataTrpcPath,
|
||||
queryCacheDefaultRefetchIntervalMs,
|
||||
queryCacheDefaultStaleTimeMs,
|
||||
queryCacheMaxValueBytes,
|
||||
queryCacheMetadataStaleTimeMs,
|
||||
} from "@homarr/api/query-cache";
|
||||
|
||||
import { queryCacheRouter } from "../router/query-cache";
|
||||
|
||||
const redisMock = vi.hoisted(() => {
|
||||
const values = new Map<string, string>();
|
||||
|
||||
return {
|
||||
values,
|
||||
setQueryCacheAsync: vi.fn(
|
||||
async (_userId: string, _boardId: string, value: string, _ttlMs: number, maxValueBytes: number) => {
|
||||
if (Buffer.byteLength(value, "utf8") > maxValueBytes) return false;
|
||||
values.set(`${_userId}:${_boardId}`, value);
|
||||
return true;
|
||||
},
|
||||
),
|
||||
removeQueryCacheAsync: vi.fn(async (_userId: string, _boardId: string) => {
|
||||
values.delete(`${_userId}:${_boardId}`);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@homarr/redis", () => ({
|
||||
setQueryCacheAsync: redisMock.setQueryCacheAsync,
|
||||
removeQueryCacheAsync: redisMock.removeQueryCacheAsync,
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
redisMock.values.clear();
|
||||
redisMock.setQueryCacheAsync.mockClear();
|
||||
redisMock.removeQueryCacheAsync.mockClear();
|
||||
});
|
||||
|
||||
const createSession = (userId = createId()): Session => ({
|
||||
user: {
|
||||
id: userId,
|
||||
permissions: [],
|
||||
colorScheme: "light",
|
||||
},
|
||||
expires: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const insertUserAsync = async (db: Database, id: string) => {
|
||||
await db.insert(users).values({ id });
|
||||
};
|
||||
|
||||
describe("isPersistableWidgetQueryKey", () => {
|
||||
describe("isWidgetDataQueryKey", () => {
|
||||
test.each([
|
||||
[[["widget", "calendar", "findAllEvents"], { type: "query" }], true],
|
||||
[[["app", "byId"], { type: "query" }], true],
|
||||
@@ -72,77 +27,22 @@ describe("isPersistableWidgetQueryKey", () => {
|
||||
[[["app", "selectable"], { type: "query" }], false],
|
||||
[[["board", "getBoardByName"], { type: "query" }], false],
|
||||
[["widget"], false],
|
||||
])("matches persist filter for %j", (queryKey, expected) => {
|
||||
expect(isPersistableWidgetQueryKey(queryKey)).toBe(expected);
|
||||
])("matches widget data queries for %j", (queryKey, expected) => {
|
||||
expect(isWidgetDataQueryKey(queryKey)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("queryCacheRouter", () => {
|
||||
test("stores and removes data for a board viewer", async () => {
|
||||
const db = createDb();
|
||||
const session = createSession();
|
||||
await insertUserAsync(db, session.user.id);
|
||||
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: session.user.id, isPublic: false });
|
||||
|
||||
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
|
||||
|
||||
await expect(caller.setItem({ boardId: "board-1", value: "cached-value" })).resolves.toEqual({
|
||||
stored: true,
|
||||
});
|
||||
expect(redisMock.setQueryCacheAsync).toHaveBeenCalledWith(
|
||||
session.user.id,
|
||||
"board-1",
|
||||
"cached-value",
|
||||
expect.any(Number),
|
||||
queryCacheMaxValueBytes,
|
||||
);
|
||||
|
||||
await caller.removeItem({ boardId: "board-1" });
|
||||
expect(redisMock.removeQueryCacheAsync).toHaveBeenCalledWith(session.user.id, "board-1");
|
||||
});
|
||||
|
||||
test("does not touch Redis when board access is denied", async () => {
|
||||
const db = createDb();
|
||||
await insertUserAsync(db, "owner");
|
||||
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: "owner", isPublic: false });
|
||||
|
||||
redisMock.setQueryCacheAsync.mockClear();
|
||||
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session: createSession("other-user") });
|
||||
|
||||
await expect(caller.setItem({ boardId: "board-1", value: "cached-value" })).rejects.toThrow("Board not found");
|
||||
expect(redisMock.setQueryCacheAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("scopes anonymous public-board cache entries by anonymous user", async () => {
|
||||
const db = createDb();
|
||||
await insertUserAsync(db, "owner");
|
||||
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: "owner", isPublic: true });
|
||||
|
||||
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
await caller.setItem({ boardId: "board-1", value: "cached-value" });
|
||||
|
||||
expect(redisMock.setQueryCacheAsync).toHaveBeenCalledWith(
|
||||
"anonymous",
|
||||
"board-1",
|
||||
"cached-value",
|
||||
expect.any(Number),
|
||||
queryCacheMaxValueBytes,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects oversized values", async () => {
|
||||
const db = createDb();
|
||||
const session = createSession("user-1");
|
||||
await insertUserAsync(db, session.user.id);
|
||||
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: session.user.id, isPublic: false });
|
||||
|
||||
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
|
||||
const value = "x".repeat(queryCacheMaxValueBytes + 1);
|
||||
|
||||
await expect(caller.setItem({ boardId: "board-1", value })).resolves.toEqual({
|
||||
stored: false,
|
||||
});
|
||||
describe("isWidgetDataTrpcPath", () => {
|
||||
test.each([
|
||||
["widget.calendar.findAllEvents", true],
|
||||
["app.byIds", true],
|
||||
["integration.byIds", true],
|
||||
["docker.getContainers", true],
|
||||
["widget.app.ping", false],
|
||||
["widget.beszel.getSystemStats", false],
|
||||
["board.getBoardByName", false],
|
||||
])("classifies dashboard data path %s", (path, expected) => {
|
||||
expect(isWidgetDataTrpcPath(path)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,3 +76,38 @@ describe("query cache stale handling", () => {
|
||||
expect(queryFn).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dashboard supporting query policies", () => {
|
||||
test("bounds metadata staleness without adding per-widget timers", () => {
|
||||
expect(dashboardSupportingQueryPolicies).toEqual([
|
||||
{
|
||||
queryKey: [["app", "byId"]],
|
||||
refetchInterval: false,
|
||||
staleTime: queryCacheMetadataStaleTimeMs,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
},
|
||||
{
|
||||
queryKey: [["app", "byIds"]],
|
||||
refetchInterval: false,
|
||||
staleTime: queryCacheMetadataStaleTimeMs,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
},
|
||||
{
|
||||
queryKey: [["integration", "byIds"]],
|
||||
refetchInterval: false,
|
||||
staleTime: queryCacheMetadataStaleTimeMs,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
},
|
||||
{
|
||||
queryKey: [["docker", "getContainers"]],
|
||||
refetchInterval: queryCacheDefaultRefetchIntervalMs,
|
||||
staleTime: queryCacheDefaultStaleTimeMs,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user