chore: reconcile widget layer after stack rebase

This commit is contained in:
ajnart committed 2026-08-14 11:07:09 +02:00
1 parent 09b82ba171
commit bd4bbcaca2
23 files changed
+101 -814

No files matched your search

@@ -1,376 +0,0 @@
// @vitest-environment node
import { stringify } from "superjson";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import type { Database } from "@homarr/db";
import { eq } from "@homarr/db";
import { boards, boardUserPermissions, customWidgetDefinitions, items, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import type { GroupPermissionKey } from "@homarr/definitions";
import { customWidgetRouter } from "../custom-widget/custom-widget-router";
import { customApiRouter } from "../widgets/custom-api";
const createSession = (userId: string, permissions: GroupPermissionKey[] = []): Session => ({
user: { id: userId, permissions, colorScheme: "light" },
expires: new Date().toISOString(),
});
const createCustomWidgetCaller = (db: Database, session: Session | null) =>
customWidgetRouter.createCaller({ db, deviceType: undefined, session });
const createCustomApiCaller = (db: Database, session: Session | null) =>
customApiRouter.createCaller({ db, deviceType: undefined, session });
async function createFixtureAsync(db: Database) {
const ownerId = createId();
const viewerId = createId();
const modifierId = createId();
const unrelatedId = createId();
const adminId = createId();
const boardId = createId();
const itemId = createId();
const definitionId = createId();
await db.insert(users).values(
[ownerId, viewerId, modifierId, unrelatedId, adminId].map((id) => ({
id,
name: id,
})),
);
await db.insert(boards).values({ id: boardId, name: `board-${boardId}`, creatorId: ownerId, isPublic: false });
await db.insert(boardUserPermissions).values([
{ boardId, userId: viewerId, permission: "view" },
{ boardId, userId: modifierId, permission: "modify" },
]);
await db.insert(customWidgetDefinitions).values({
id: definitionId,
name: "Restart service",
description: "Restarts a service",
url: "https://api.example.com/restart",
method: "POST",
displayType: "actionButton",
displayConfig: stringify({
type: "actionButton",
buttonLabel: "Restart",
confirmText: "Continue?",
}),
creatorId: adminId,
});
await db.insert(items).values({
id: itemId,
boardId,
kind: "customApi",
options: stringify({ definitionId, refreshInterval: 30 }),
});
return { ownerId, viewerId, modifierId, unrelatedId, adminId, boardId, itemId, definitionId };
}
beforeEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
describe("custom widget definition authorization", () => {
it("restricts the full definition list to administrators", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
await expect(createCustomWidgetCaller(db, null).all()).rejects.toMatchObject({ code: "UNAUTHORIZED" });
for (const userId of [fixture.ownerId, fixture.viewerId, fixture.modifierId, fixture.unrelatedId]) {
await expect(createCustomWidgetCaller(db, createSession(userId)).all()).rejects.toMatchObject({
code: "FORBIDDEN",
});
}
await expect(createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).all()).resolves.toEqual([
expect.objectContaining({ id: fixture.definitionId, url: "https://api.example.com/restart" }),
]);
});
it("returns only safe definition summaries to board modifiers", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
const result = await createCustomWidgetCaller(db, createSession(fixture.modifierId)).available({
boardId: fixture.boardId,
});
expect(result).toEqual([
{
id: fixture.definitionId,
name: "Restart service",
description: "Restarts a service",
iconUrl: null,
displayType: "actionButton",
enabled: true,
},
]);
expect(JSON.stringify(result)).not.toContain("api.example.com");
for (const userId of [fixture.viewerId, fixture.unrelatedId]) {
await expect(
createCustomWidgetCaller(db, createSession(userId)).available({ boardId: fixture.boardId }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
}
});
});
describe("custom widget action authorization", () => {
it("requires an administrator even for users who can view or modify the board", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
const input = {
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
};
await expect(createCustomWidgetCaller(db, null).execute(input)).rejects.toMatchObject({ code: "UNAUTHORIZED" });
for (const userId of [fixture.ownerId, fixture.viewerId, fixture.modifierId, fixture.unrelatedId]) {
await expect(createCustomWidgetCaller(db, createSession(userId)).execute(input)).rejects.toMatchObject({
code: "FORBIDDEN",
});
}
});
it("executes for an administrator only when board, item, and definition are bound", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal("fetch", fetchMock);
const caller = createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"]));
await expect(
caller.execute({
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
}),
).resolves.toEqual({ success: true, responseInfo: { status: 204 } });
expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/restart",
expect.objectContaining({ method: "POST", redirect: "error", signal: expect.any(AbortSignal) }),
);
});
it.each(["board", "item", "definition"] as const)("rejects a mismatched %s binding", async (mismatch) => {
const db = createDb();
const fixture = await createFixtureAsync(db);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const input = {
boardId: mismatch === "board" ? createId() : fixture.boardId,
itemId: mismatch === "item" ? createId() : fixture.itemId,
definitionId: mismatch === "definition" ? createId() : fixture.definitionId,
};
await expect(
createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute(input),
).rejects.toMatchObject({ code: "NOT_FOUND" });
expect(fetchMock).not.toHaveBeenCalled();
});
it("rejects disabled definitions before sending a request", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
await db
.update(customWidgetDefinitions)
.set({ enabled: false })
.where(eq(customWidgetDefinitions.id, fixture.definitionId));
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(
createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
}),
).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe("custom API outbound request hardening", () => {
it("rejects credential-bearing action URLs without exposing the credential", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
await db
.update(customWidgetDefinitions)
.set({ url: "https://operator:super-secret@private.example.com/restart" })
.where(eq(customWidgetDefinitions.id, fixture.definitionId));
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const result = await createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
});
expect(result).toEqual({ success: false, errorCode: "REQUEST_FAILED", responseInfo: null });
expect(JSON.stringify(result)).not.toMatch(/super-secret|private\.example\.com/);
expect(fetchMock).not.toHaveBeenCalled();
});
it("rejects redirects and redacts fetch errors", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
const fetchMock = vi
.fn()
.mockRejectedValue(
new TypeError("Redirect to http://token:secret@internal.example.local/admin?apiKey=top-secret was rejected"),
);
vi.stubGlobal("fetch", fetchMock);
const result = await createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
});
expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/restart",
expect.objectContaining({ redirect: "error" }),
);
expect(result).toEqual({ success: false, errorCode: "REQUEST_FAILED", responseInfo: null });
expect(JSON.stringify(result)).not.toMatch(/top-secret|internal\.example\.local|apiKey/);
});
it("aborts timed-out action requests and returns a stable failure", async () => {
vi.useFakeTimers();
const db = createDb();
const fixture = await createFixtureAsync(db);
const started = Promise.withResolvers<void>();
const fetchMock = vi.fn((_url: string, init: RequestInit) => {
started.resolve();
return new Promise<Response>((_resolve, reject) => {
init.signal?.addEventListener("abort", () => {
reject(new Error("Timed out calling http://internal.example.local?secret=timeout-secret"));
});
});
});
vi.stubGlobal("fetch", fetchMock);
const request = createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
});
await started.promise;
await vi.advanceTimersByTimeAsync(10_000);
const result = await request;
expect(result).toEqual({ success: false, errorCode: "REQUEST_FAILED", responseInfo: null });
expect(JSON.stringify(result)).not.toMatch(/timeout-secret|internal\.example\.local/);
});
it("bounds action responses before reporting success", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("x".repeat(2 * 1024 * 1024 + 1))));
await expect(
createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
}),
).resolves.toEqual({ success: false, errorCode: "REQUEST_FAILED", responseInfo: null });
});
it("binds data requests to a viewable item and never exposes backend error text", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
await db
.update(customWidgetDefinitions)
.set({
displayType: "singleValue",
method: "GET",
displayConfig: stringify({ type: "singleValue", jsonPath: "$.value" }),
})
.where(eq(customWidgetDefinitions.id, fixture.definitionId));
vi.stubGlobal(
"fetch",
vi.fn().mockRejectedValue(new Error("Backend http://private.example.local/path?token=super-secret\nstack trace")),
);
const input = {
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
};
const error = await createCustomApiCaller(db, createSession(fixture.viewerId))
.getData(input)
.catch((caught: unknown) => caught);
expect(error).toMatchObject({ code: "INTERNAL_SERVER_ERROR", message: "Custom API request failed" });
expect(JSON.stringify(error)).not.toMatch(/super-secret|private\.example\.local|stack trace/);
await expect(createCustomApiCaller(db, createSession(fixture.unrelatedId)).getData(input)).rejects.toMatchObject({
code: "NOT_FOUND",
});
await expect(
createCustomApiCaller(db, createSession(fixture.viewerId)).getData({ ...input, definitionId: createId() }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("bounds and disposes non-success data responses", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
await db
.update(customWidgetDefinitions)
.set({
displayType: "singleValue",
method: "GET",
displayConfig: stringify({ type: "singleValue", jsonPath: "$.value" }),
})
.where(eq(customWidgetDefinitions.id, fixture.definitionId));
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("x".repeat(2 * 1024 * 1024 + 1), { status: 502 })));
const error = await createCustomApiCaller(db, createSession(fixture.viewerId))
.getData({
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
})
.catch((caught: unknown) => caught);
expect(error).toMatchObject({ code: "INTERNAL_SERVER_ERROR", message: "Custom API request failed" });
expect(JSON.stringify(error)).not.toContain("x".repeat(100));
});
it("redacts unsafe preview failures and rejects redirects", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
const fetchMock = vi.fn().mockRejectedValue(new Error("redirected to http://private.local?secret=value"));
vi.stubGlobal("fetch", fetchMock);
const caller = createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"]));
const result = await caller.preview({
url: "https://api.example.com/data",
method: "GET",
authType: "none",
displayType: "singleValue",
displayConfig: { type: "singleValue", jsonPath: "$.value", label: "Value", unit: "" },
secrets: [],
});
expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/data",
expect.objectContaining({ redirect: "error" }),
);
expect(result).toEqual({
success: false,
error: "Custom API preview failed",
responseInfo: null,
rawResponse: null,
});
expect(JSON.stringify(result)).not.toMatch(/private\.local|secret=value/);
});
});
+1 -1
View File
@@ -32,7 +32,7 @@ const feedsInput = z.object({
export const rssFeedRouter = createTRPCRouter({
getFeeds: publicProcedure.input(feedsInput).query(async ({ ctx, input }) => {
if (env.NO_EXTERNAL_CONNECTION) return [];
if (env.NO_EXTERNAL_CONNECTION) return { entries: [], failedFeedCount: 0 };
const urls = (await canAccessAllFeedsAsync(ctx.db, ctx.session))
? input.urls