merge: stack assistant on mobile boards
This commit is contained in:
1326 files changed
+131355
-46380
No files matched your search
@@ -4,7 +4,7 @@ import { describe, expect, it, test, vi } from "vitest";
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import type { Database, InferInsertModel } from "@homarr/db";
|
||||
import { and, eq, not } from "@homarr/db";
|
||||
import { and, eq } from "@homarr/db";
|
||||
import {
|
||||
boardGroupPermissions,
|
||||
boards,
|
||||
@@ -325,10 +325,13 @@ describe("createBoard should create a new board", () => {
|
||||
expect(firstSection?.xOffset).toBe(0);
|
||||
expect(firstSection?.yOffset).toBe(0);
|
||||
|
||||
expect(dbBoard?.layouts.length).toBe(1);
|
||||
const firstLayout = dbBoard?.layouts.at(0);
|
||||
expect(firstLayout?.columnCount).toBe(24);
|
||||
expect(firstLayout?.breakpoint).toBe(0);
|
||||
expect(dbBoard?.layouts.length).toBe(2);
|
||||
expect(dbBoard?.layouts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: "Mobile", role: "mobile", columnCount: 3, breakpoint: 0 }),
|
||||
expect.objectContaining({ name: "Base", role: "base", columnCount: 24, breakpoint: 768 }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw error when user has no board-create permission", async () => {
|
||||
@@ -344,6 +347,36 @@ describe("createBoard should create a new board", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicateBoard should preserve protected layout invariants", () => {
|
||||
test("normalizes a legacy single-layout board while preserving Base positions", async () => {
|
||||
const db = createDb();
|
||||
const session = {
|
||||
...defaultSession,
|
||||
user: {
|
||||
...defaultSession.user,
|
||||
permissions: ["board-create"] satisfies GroupPermissionKey[],
|
||||
},
|
||||
};
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session });
|
||||
const { boardId } = await createFullBoardAsync(db, "source");
|
||||
|
||||
const result = await caller.duplicateBoard({ id: boardId, name: "copy" });
|
||||
|
||||
const copiedBoard = await db.query.boards.findFirst({
|
||||
where: eq(boards.id, result.boardId),
|
||||
with: { layouts: true, items: { with: { layouts: true } } },
|
||||
});
|
||||
expect(copiedBoard?.layouts).toHaveLength(2);
|
||||
expect(copiedBoard?.layouts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ role: "mobile", columnCount: 3, breakpoint: 0 }),
|
||||
expect.objectContaining({ role: "base", columnCount: 10, breakpoint: 768 }),
|
||||
]),
|
||||
);
|
||||
expect(copiedBoard?.items.at(0)?.layouts).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rename board should rename board", () => {
|
||||
test("should rename board", async () => {
|
||||
// Arrange
|
||||
@@ -569,9 +602,48 @@ describe("getBoardByName should return board by name", () => {
|
||||
name,
|
||||
...fullBoardProps,
|
||||
});
|
||||
expect(result.layouts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ role: "mobile", breakpoint: 0, columnCount: 3 }),
|
||||
expect.objectContaining({ id: fullBoardProps.layoutId, role: "base", breakpoint: 768 }),
|
||||
]),
|
||||
);
|
||||
expect(result.items.at(0)?.layouts).toHaveLength(2);
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "view");
|
||||
});
|
||||
|
||||
it("returns configured container behavior", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, sectionId: rootSectionId, layoutId } = await createFullBoardAsync(db, "default");
|
||||
const sectionId = await addContainerAsync(db, {
|
||||
boardId,
|
||||
layoutId,
|
||||
parentSectionId: rootSectionId,
|
||||
options: {
|
||||
title: "Media",
|
||||
showLabel: false,
|
||||
collapsible: true,
|
||||
showOpenAll: true,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await caller.getBoardByName({ name: "default" });
|
||||
const section = expectToBeDefined(result.sections.find((candidate) => candidate.id === sectionId));
|
||||
|
||||
expect(result.id).toBe(boardId);
|
||||
expect(section.kind).toBe("container");
|
||||
if (section.kind === "container") {
|
||||
expect(section.collapsed).toBe(false);
|
||||
expect(section.options).toMatchObject({
|
||||
title: "Media",
|
||||
showLabel: false,
|
||||
collapsible: true,
|
||||
showOpenAll: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("should throw error when not present", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
@@ -850,60 +922,194 @@ describe("saveBoard should save full board", () => {
|
||||
expect(integration).toBeUndefined();
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
|
||||
});
|
||||
it.each([[{ kind: "empty" as const }], [{ kind: "category" as const, collapsed: false, name: "My first category" }]])(
|
||||
"should add section when present in input",
|
||||
async (partialSection) => {
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
it("should add root section when present in input", async () => {
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
|
||||
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
|
||||
|
||||
const newSectionId = createId();
|
||||
await caller.saveBoard({
|
||||
id: boardId,
|
||||
sections: [
|
||||
{
|
||||
id: newSectionId,
|
||||
xOffset: 0,
|
||||
yOffset: 1,
|
||||
...partialSection,
|
||||
},
|
||||
{
|
||||
id: sectionId,
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
},
|
||||
],
|
||||
items: [],
|
||||
});
|
||||
|
||||
const board = await db.query.boards.findFirst({
|
||||
where: eq(boards.id, boardId),
|
||||
with: {
|
||||
sections: true,
|
||||
const newSectionId = createId();
|
||||
await caller.saveBoard({
|
||||
id: boardId,
|
||||
sections: [
|
||||
{
|
||||
id: newSectionId,
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: 1,
|
||||
},
|
||||
});
|
||||
{
|
||||
id: sectionId,
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
},
|
||||
],
|
||||
items: [],
|
||||
});
|
||||
|
||||
const section = await db.query.sections.findFirst({
|
||||
where: eq(sections.id, newSectionId),
|
||||
});
|
||||
const board = await db.query.boards.findFirst({
|
||||
where: eq(boards.id, boardId),
|
||||
with: {
|
||||
sections: true,
|
||||
},
|
||||
});
|
||||
|
||||
const definedBoard = expectToBeDefined(board);
|
||||
expect(definedBoard.sections.length).toBe(2);
|
||||
const addedSection = expectToBeDefined(definedBoard.sections.find((section) => section.id === newSectionId));
|
||||
expect(addedSection).toBeDefined();
|
||||
expect(addedSection.id).toBe(newSectionId);
|
||||
expect(addedSection.kind).toBe(partialSection.kind);
|
||||
expect(addedSection.yOffset).toBe(1);
|
||||
if ("name" in partialSection) {
|
||||
expect(addedSection.name).toBe(partialSection.name);
|
||||
}
|
||||
expect(section).toBeDefined();
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
|
||||
},
|
||||
);
|
||||
const section = await db.query.sections.findFirst({
|
||||
where: eq(sections.id, newSectionId),
|
||||
});
|
||||
|
||||
const definedBoard = expectToBeDefined(board);
|
||||
expect(definedBoard.sections.length).toBe(2);
|
||||
const addedSection = expectToBeDefined(definedBoard.sections.find((section) => section.id === newSectionId));
|
||||
expect(addedSection).toBeDefined();
|
||||
expect(addedSection.id).toBe(newSectionId);
|
||||
expect(addedSection.kind).toBe("empty");
|
||||
expect(addedSection.yOffset).toBe(1);
|
||||
expect(addedSection.name).toBeNull();
|
||||
expect(section).toBeDefined();
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
|
||||
});
|
||||
it("should add and update container behavior options", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, layoutId, sectionId: rootSectionId } = await createFullBoardAsync(db, "default");
|
||||
const containerId = createId();
|
||||
|
||||
await caller.saveBoard({
|
||||
id: boardId,
|
||||
sections: [
|
||||
{
|
||||
id: rootSectionId,
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
},
|
||||
{
|
||||
id: containerId,
|
||||
kind: "container",
|
||||
collapsed: false,
|
||||
options: {
|
||||
title: "Navigation",
|
||||
customCssClasses: ["pinned"],
|
||||
borderColor: "#123456",
|
||||
showLabel: true,
|
||||
collapsible: true,
|
||||
showOpenAll: true,
|
||||
},
|
||||
layouts: [
|
||||
{
|
||||
layoutId,
|
||||
parentSectionId: rootSectionId,
|
||||
xOffset: 1,
|
||||
yOffset: 2,
|
||||
width: 3,
|
||||
height: 4,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
items: [],
|
||||
});
|
||||
|
||||
await caller.saveBoard({
|
||||
id: boardId,
|
||||
sections: [
|
||||
{
|
||||
id: rootSectionId,
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
},
|
||||
{
|
||||
id: containerId,
|
||||
kind: "container",
|
||||
collapsed: false,
|
||||
options: {
|
||||
title: "Operations",
|
||||
customCssClasses: ["dense"],
|
||||
borderColor: "#654321",
|
||||
showLabel: false,
|
||||
collapsible: false,
|
||||
showOpenAll: false,
|
||||
},
|
||||
layouts: [
|
||||
{
|
||||
layoutId,
|
||||
parentSectionId: rootSectionId,
|
||||
xOffset: 2,
|
||||
yOffset: 3,
|
||||
width: 4,
|
||||
height: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
items: [],
|
||||
});
|
||||
|
||||
const section = await db.query.sections.findFirst({
|
||||
where: eq(sections.id, containerId),
|
||||
});
|
||||
|
||||
expect(SuperJSON.parse(section?.options ?? "")).toEqual({
|
||||
title: "Operations",
|
||||
customCssClasses: ["dense"],
|
||||
borderColor: "#654321",
|
||||
showLabel: false,
|
||||
collapsible: false,
|
||||
showOpenAll: false,
|
||||
});
|
||||
expect(
|
||||
await db.query.sectionLayouts.findFirst({
|
||||
where: and(eq(sectionLayouts.sectionId, containerId), eq(sectionLayouts.layoutId, layoutId)),
|
||||
}),
|
||||
).toMatchObject({
|
||||
parentSectionId: rootSectionId,
|
||||
xOffset: 2,
|
||||
yOffset: 3,
|
||||
width: 4,
|
||||
height: 5,
|
||||
});
|
||||
});
|
||||
it("should add a container without layouts", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, sectionId: rootSectionId } = await createFullBoardAsync(db, "default");
|
||||
const containerId = createId();
|
||||
|
||||
await caller.saveBoard({
|
||||
id: boardId,
|
||||
sections: [
|
||||
{
|
||||
id: rootSectionId,
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
},
|
||||
{
|
||||
id: containerId,
|
||||
kind: "container",
|
||||
collapsed: false,
|
||||
options: {},
|
||||
layouts: [],
|
||||
},
|
||||
],
|
||||
items: [],
|
||||
});
|
||||
|
||||
expect(
|
||||
await db.query.sections.findFirst({
|
||||
where: eq(sections.id, containerId),
|
||||
}),
|
||||
).toMatchObject({ id: containerId, kind: "container" });
|
||||
expect(
|
||||
await db.query.sectionLayouts.findMany({
|
||||
where: eq(sectionLayouts.sectionId, containerId),
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
it("should add item when present in input", async () => {
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
@@ -1049,15 +1255,22 @@ describe("saveBoard should save full board", () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
|
||||
const newSectionId = createId();
|
||||
const { boardId, sectionId, layoutId } = await createFullBoardAsync(db, "default");
|
||||
const containerId = createId();
|
||||
await db.insert(sections).values({
|
||||
id: newSectionId,
|
||||
kind: "category",
|
||||
name: "Before",
|
||||
yOffset: 1,
|
||||
xOffset: 0,
|
||||
id: containerId,
|
||||
kind: "container",
|
||||
boardId,
|
||||
options: SuperJSON.stringify({ title: "Before" }),
|
||||
});
|
||||
await db.insert(sectionLayouts).values({
|
||||
sectionId: containerId,
|
||||
layoutId,
|
||||
parentSectionId: sectionId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 2,
|
||||
height: 2,
|
||||
});
|
||||
|
||||
await caller.saveBoard({
|
||||
@@ -1065,19 +1278,32 @@ describe("saveBoard should save full board", () => {
|
||||
sections: [
|
||||
{
|
||||
id: sectionId,
|
||||
kind: "category",
|
||||
kind: "empty",
|
||||
yOffset: 1,
|
||||
xOffset: 0,
|
||||
name: "Test",
|
||||
collapsed: true,
|
||||
},
|
||||
{
|
||||
id: newSectionId,
|
||||
kind: "category",
|
||||
name: "After",
|
||||
yOffset: 0,
|
||||
xOffset: 0,
|
||||
id: containerId,
|
||||
kind: "container",
|
||||
collapsed: false,
|
||||
options: {
|
||||
title: "After",
|
||||
customCssClasses: ["dense"],
|
||||
borderColor: "#123456",
|
||||
showLabel: false,
|
||||
collapsible: true,
|
||||
showOpenAll: true,
|
||||
},
|
||||
layouts: [
|
||||
{
|
||||
layoutId,
|
||||
parentSectionId: sectionId,
|
||||
xOffset: 1,
|
||||
yOffset: 2,
|
||||
width: 3,
|
||||
height: 4,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
items: [],
|
||||
@@ -1097,11 +1323,17 @@ describe("saveBoard should save full board", () => {
|
||||
expect(firstSection.kind).toBe("empty");
|
||||
expect(firstSection.yOffset).toBe(1);
|
||||
expect(firstSection.name).toBe(null);
|
||||
const secondSection = expectToBeDefined(definedBoard.sections.find((section) => section.id === newSectionId));
|
||||
expect(secondSection.id).toBe(newSectionId);
|
||||
expect(secondSection.kind).toBe("category");
|
||||
expect(secondSection.yOffset).toBe(0);
|
||||
expect(secondSection.name).toBe("After");
|
||||
const secondSection = expectToBeDefined(definedBoard.sections.find((section) => section.id === containerId));
|
||||
expect(secondSection.id).toBe(containerId);
|
||||
expect(secondSection.kind).toBe("container");
|
||||
expect(secondSection.yOffset).toBeNull();
|
||||
expect(secondSection.name).toBeNull();
|
||||
expect(SuperJSON.parse(secondSection.options ?? "")).toMatchObject({ title: "After", collapsible: true });
|
||||
expect(
|
||||
await db.query.sectionLayouts.findFirst({
|
||||
where: and(eq(sectionLayouts.sectionId, containerId), eq(sectionLayouts.layoutId, layoutId)),
|
||||
}),
|
||||
).toMatchObject({ xOffset: 1, yOffset: 2, width: 3, height: 4, parentSectionId: sectionId });
|
||||
});
|
||||
it("should update item when present in input", async () => {
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
@@ -1160,12 +1392,12 @@ describe("saveBoard should save full board", () => {
|
||||
expect(firstItem.id).toBe(itemId);
|
||||
expect(firstItem.kind).toBe("clock");
|
||||
expect(SuperJSON.parse<{ is24HourFormat: boolean }>(firstItem.options).is24HourFormat).toBe(false);
|
||||
const firstLayout = expectToBeDefined(firstItem.layouts[0]);
|
||||
expect(firstLayout.sectionId).toBe(sectionId);
|
||||
expect(firstLayout.height).toBe(3);
|
||||
expect(firstLayout.width).toBe(2);
|
||||
expect(firstLayout.xOffset).toBe(7);
|
||||
expect(firstLayout.yOffset).toBe(5);
|
||||
const updatedLayout = expectToBeDefined(firstItem.layouts.find((layout) => layout.layoutId === layoutId));
|
||||
expect(updatedLayout.sectionId).toBe(sectionId);
|
||||
expect(updatedLayout.height).toBe(3);
|
||||
expect(updatedLayout.width).toBe(2);
|
||||
expect(updatedLayout.xOffset).toBe(7);
|
||||
expect(updatedLayout.yOffset).toBe(5);
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
|
||||
});
|
||||
it("should fail when board not found", async () => {
|
||||
@@ -1350,14 +1582,43 @@ const createExistingLayout = (id: string) => ({
|
||||
id,
|
||||
name: "Base",
|
||||
columnCount: 10,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 768,
|
||||
role: "base" as const,
|
||||
});
|
||||
const createMobileLayout = (id: string) => ({
|
||||
id,
|
||||
name: "Mobile",
|
||||
columnCount: 3,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
role: "mobile" as const,
|
||||
});
|
||||
const createNewLayout = (columnCount: number) => ({
|
||||
id: createId(),
|
||||
name: "New layout",
|
||||
columnCount,
|
||||
breakpoint: 1400,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 480,
|
||||
role: "custom" as const,
|
||||
});
|
||||
const addMobileLayoutAsync = async (db: Database, boardId: string) => {
|
||||
const mobileLayoutId = createId();
|
||||
await db.insert(layouts).values({
|
||||
id: mobileLayoutId,
|
||||
name: "Mobile",
|
||||
columnCount: 3,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
role: "mobile",
|
||||
boardId,
|
||||
});
|
||||
return mobileLayoutId;
|
||||
};
|
||||
describe("saveLayouts should save layout changes", () => {
|
||||
test("should add layout when not present in database", async () => {
|
||||
// Arrange
|
||||
@@ -1365,30 +1626,34 @@ describe("saveLayouts should save layout changes", () => {
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
|
||||
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
|
||||
const newLayout = createNewLayout(12);
|
||||
|
||||
// Act
|
||||
await caller.saveLayouts({
|
||||
const savedLayouts = await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [createExistingLayout(layoutId), newLayout],
|
||||
layouts: [createMobileLayout(mobileLayoutId), createExistingLayout(layoutId), newLayout],
|
||||
});
|
||||
|
||||
// Assert
|
||||
const layout = await db.query.layouts.findFirst({
|
||||
where: not(eq(layouts.id, layoutId)),
|
||||
where: eq(layouts.role, "custom"),
|
||||
});
|
||||
|
||||
const definedLayout = expectToBeDefined(layout);
|
||||
expect(definedLayout.name).toBe(newLayout.name);
|
||||
expect(definedLayout.columnCount).toBe(newLayout.columnCount);
|
||||
expect(definedLayout.breakpoint).toBe(newLayout.breakpoint);
|
||||
expect(definedLayout.role).toBe("custom");
|
||||
expect(savedLayouts.find((candidate) => candidate.role === "custom")?.id).toBe(definedLayout.id);
|
||||
});
|
||||
test("should add items and dynamic sections generated from grid-algorithm when new layout is added", async () => {
|
||||
test("should add items and containers generated from grid-algorithm when new layout is added", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default");
|
||||
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
|
||||
const assignments = await createItemsAndSectionsAsync(db, {
|
||||
boardId,
|
||||
layoutId,
|
||||
@@ -1399,12 +1664,12 @@ describe("saveLayouts should save layout changes", () => {
|
||||
// Act
|
||||
await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [createExistingLayout(layoutId), newLayout],
|
||||
layouts: [createMobileLayout(mobileLayoutId), createExistingLayout(layoutId), newLayout],
|
||||
});
|
||||
|
||||
// Assert
|
||||
const layout = await db.query.layouts.findFirst({
|
||||
where: not(eq(layouts.id, layoutId)),
|
||||
where: eq(layouts.role, "custom"),
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
await expectLayoutForRootLayoutAsync(db, sectionId, layout!.id, {
|
||||
@@ -1412,7 +1677,7 @@ describe("saveLayouts should save layout changes", () => {
|
||||
a: itemId,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layout!.id, assignments.inDynamicSection);
|
||||
await expectLayoutForContainerAsync(db, assignments.inRoot.f, layout!.id, assignments.inContainer);
|
||||
});
|
||||
test("should update layout when present in input", async () => {
|
||||
// Arrange
|
||||
@@ -1420,6 +1685,7 @@ describe("saveLayouts should save layout changes", () => {
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
|
||||
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
|
||||
const updatedLayout = createExistingLayout(layoutId);
|
||||
updatedLayout.breakpoint = 1400;
|
||||
updatedLayout.name = "Updated layout";
|
||||
@@ -1427,7 +1693,7 @@ describe("saveLayouts should save layout changes", () => {
|
||||
// Act
|
||||
await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [updatedLayout],
|
||||
layouts: [createMobileLayout(mobileLayoutId), updatedLayout],
|
||||
});
|
||||
|
||||
// Assert
|
||||
@@ -1446,6 +1712,7 @@ describe("saveLayouts should save layout changes", () => {
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default");
|
||||
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
|
||||
const assignments = await createItemsAndSectionsAsync(db, {
|
||||
boardId,
|
||||
layoutId,
|
||||
@@ -1457,7 +1724,7 @@ describe("saveLayouts should save layout changes", () => {
|
||||
// Act
|
||||
await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [updatedLayout],
|
||||
layouts: [createMobileLayout(mobileLayoutId), updatedLayout],
|
||||
});
|
||||
|
||||
// Assert
|
||||
@@ -1465,7 +1732,126 @@ describe("saveLayouts should save layout changes", () => {
|
||||
...assignments.inRoot,
|
||||
a: itemId,
|
||||
});
|
||||
await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layoutId, assignments.inDynamicSection);
|
||||
await expectLayoutForContainerAsync(db, assignments.inRoot.f, layoutId, assignments.inContainer);
|
||||
});
|
||||
test("should reserve gutter columns and return gutter contents to the canvas when disabled", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "gutters");
|
||||
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
|
||||
|
||||
await db
|
||||
.update(itemLayouts)
|
||||
.set({
|
||||
xOffset: 9,
|
||||
})
|
||||
.where(and(eq(itemLayouts.itemId, itemId), eq(itemLayouts.layoutId, layoutId)));
|
||||
|
||||
await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [
|
||||
createMobileLayout(mobileLayoutId),
|
||||
{
|
||||
...createExistingLayout(layoutId),
|
||||
leftGutterColumnCount: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const enabledLayout = expectToBeDefined(await db.query.layouts.findFirst({ where: eq(layouts.id, layoutId) }));
|
||||
expect(enabledLayout.leftGutterColumnCount).toBe(2);
|
||||
const leftRoot = expectToBeDefined(
|
||||
await db.query.sections.findFirst({
|
||||
where: and(eq(sections.boardId, boardId), eq(sections.kind, "empty"), eq(sections.xOffset, -1)),
|
||||
}),
|
||||
);
|
||||
const reflowedMainItem = expectToBeDefined(
|
||||
await db.query.itemLayouts.findFirst({
|
||||
where: and(eq(itemLayouts.itemId, itemId), eq(itemLayouts.layoutId, layoutId)),
|
||||
}),
|
||||
);
|
||||
expect(reflowedMainItem.xOffset + reflowedMainItem.width).toBeLessThanOrEqual(8);
|
||||
|
||||
const gutterItemId = await addItemAsync(db, {
|
||||
boardId,
|
||||
layoutId,
|
||||
sectionId: leftRoot.id,
|
||||
});
|
||||
await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [createMobileLayout(mobileLayoutId), createExistingLayout(layoutId)],
|
||||
});
|
||||
|
||||
const returnedItem = expectToBeDefined(
|
||||
await db.query.itemLayouts.findFirst({
|
||||
where: and(eq(itemLayouts.itemId, gutterItemId), eq(itemLayouts.layoutId, layoutId)),
|
||||
}),
|
||||
);
|
||||
expect(returnedItem.sectionId).toBe(sectionId);
|
||||
});
|
||||
test("should create one gutter root when layouts are saved concurrently", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, layoutId } = await createFullBoardAsync(db, "concurrent-gutters");
|
||||
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
|
||||
const input = {
|
||||
id: boardId,
|
||||
layouts: [
|
||||
createMobileLayout(mobileLayoutId),
|
||||
{
|
||||
...createExistingLayout(layoutId),
|
||||
leftGutterColumnCount: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await Promise.all([caller.saveLayouts(input), caller.saveLayouts(input)]);
|
||||
|
||||
const leftRoots = await db.query.sections.findMany({
|
||||
where: and(eq(sections.boardId, boardId), eq(sections.kind, "empty"), eq(sections.xOffset, -1)),
|
||||
});
|
||||
expect(leftRoots).toHaveLength(1);
|
||||
});
|
||||
test("should project a new custom layout from the resized Base layout", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default");
|
||||
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
|
||||
const secondItemId = createId();
|
||||
|
||||
await db.update(itemLayouts).set({ width: 4, xOffset: 0 }).where(eq(itemLayouts.itemId, itemId));
|
||||
await db.insert(items).values({
|
||||
id: secondItemId,
|
||||
kind: "clock",
|
||||
boardId,
|
||||
options: SuperJSON.stringify({ is24HourFormat: true }),
|
||||
});
|
||||
await db.insert(itemLayouts).values({
|
||||
itemId: secondItemId,
|
||||
layoutId,
|
||||
sectionId,
|
||||
width: 4,
|
||||
height: 1,
|
||||
xOffset: 6,
|
||||
yOffset: 0,
|
||||
});
|
||||
|
||||
const resizedBase = { ...createExistingLayout(layoutId), columnCount: 6 };
|
||||
const savedLayouts = await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [createMobileLayout(mobileLayoutId), createNewLayout(8), resizedBase],
|
||||
});
|
||||
const customLayoutId = expectToBeDefined(savedLayouts.find((layout) => layout.role === "custom")).id;
|
||||
|
||||
const basePosition = await db.query.itemLayouts.findFirst({
|
||||
where: and(eq(itemLayouts.itemId, secondItemId), eq(itemLayouts.layoutId, layoutId)),
|
||||
});
|
||||
const customPosition = await db.query.itemLayouts.findFirst({
|
||||
where: and(eq(itemLayouts.itemId, secondItemId), eq(itemLayouts.layoutId, customLayoutId)),
|
||||
});
|
||||
|
||||
expect(basePosition).toMatchObject({ xOffset: 0, yOffset: 1, width: 4, height: 1, sectionId });
|
||||
expect(customPosition).toMatchObject({ xOffset: 0, yOffset: 1, width: 4, height: 1, sectionId });
|
||||
});
|
||||
test("should remove layout when not present in input", async () => {
|
||||
// Arrange
|
||||
@@ -1473,16 +1859,26 @@ describe("saveLayouts should save layout changes", () => {
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
|
||||
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
|
||||
const customLayoutId = createId();
|
||||
await db.insert(layouts).values({
|
||||
id: customLayoutId,
|
||||
name: "Custom",
|
||||
columnCount: 6,
|
||||
breakpoint: 480,
|
||||
role: "custom",
|
||||
boardId,
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [createNewLayout(12)],
|
||||
layouts: [createMobileLayout(mobileLayoutId), createExistingLayout(layoutId)],
|
||||
});
|
||||
|
||||
// Assert
|
||||
const layout = await db.query.layouts.findFirst({
|
||||
where: eq(layouts.id, layoutId),
|
||||
where: eq(layouts.id, customLayoutId),
|
||||
});
|
||||
expect(layout).toBeUndefined();
|
||||
});
|
||||
@@ -1497,12 +1893,130 @@ describe("saveLayouts should save layout changes", () => {
|
||||
const actAsync = async () =>
|
||||
await caller.saveLayouts({
|
||||
id: createId(),
|
||||
layouts: [createExistingLayout(layoutId)],
|
||||
layouts: [createMobileLayout(createId()), createExistingLayout(layoutId)],
|
||||
});
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrowError("Board not found");
|
||||
});
|
||||
test("should reject changing protected layout roles", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
|
||||
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
|
||||
|
||||
const actAsync = async () =>
|
||||
await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [
|
||||
{ ...createExistingLayout(layoutId), role: "mobile", breakpoint: 0 },
|
||||
{ ...createMobileLayout(mobileLayoutId), role: "base", breakpoint: 768 },
|
||||
],
|
||||
});
|
||||
|
||||
await expect(actAsync()).rejects.toThrowError("Layout roles cannot be changed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetLayout should rebuild a layout from Base", () => {
|
||||
test("should replace target item positions and preserve layout metadata", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, sectionId, itemId } = await createFullBoardAsync(db, "default");
|
||||
await addMobileLayoutAsync(db, boardId);
|
||||
const customLayoutId = createId();
|
||||
await db.insert(layouts).values({
|
||||
id: customLayoutId,
|
||||
name: "Tablet",
|
||||
columnCount: 6,
|
||||
breakpoint: 480,
|
||||
role: "custom",
|
||||
boardId,
|
||||
});
|
||||
await db.insert(itemLayouts).values({
|
||||
itemId,
|
||||
sectionId,
|
||||
layoutId: customLayoutId,
|
||||
width: 2,
|
||||
height: 2,
|
||||
xOffset: 3,
|
||||
yOffset: 4,
|
||||
});
|
||||
|
||||
await caller.resetLayout({ boardId, layoutId: customLayoutId });
|
||||
|
||||
const resetPosition = await db.query.itemLayouts.findFirst({
|
||||
where: and(eq(itemLayouts.itemId, itemId), eq(itemLayouts.layoutId, customLayoutId)),
|
||||
});
|
||||
expect(resetPosition).toMatchObject({ width: 1, height: 1, xOffset: 0, yOffset: 0, sectionId });
|
||||
const customLayout = await db.query.layouts.findFirst({ where: eq(layouts.id, customLayoutId) });
|
||||
expect(customLayout).toMatchObject({ name: "Tablet", columnCount: 6, breakpoint: 480, role: "custom" });
|
||||
});
|
||||
|
||||
test("should reject resetting Base", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
|
||||
|
||||
await expect(caller.resetLayout({ boardId, layoutId })).rejects.toThrowError("Base layout cannot be reset");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Custom Widget placement permissions", () => {
|
||||
test("rejects a direct addItem request from a non-admin board modifier", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId } = await createFullBoardAsync(db, "custom-widget-add");
|
||||
|
||||
await expect(
|
||||
caller.addItem({
|
||||
boardId,
|
||||
kind: "customApi",
|
||||
options: {
|
||||
definitionId: "definition-1",
|
||||
refreshInterval: 30,
|
||||
configuration: {},
|
||||
},
|
||||
integrationIds: [],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
|
||||
test("rejects a direct saveBoard request that adds a Custom Widget for a non-admin", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, sectionId, layoutId } = await createFullBoardAsync(db, "custom-widget-save");
|
||||
|
||||
await expect(
|
||||
caller.saveBoard({
|
||||
id: boardId,
|
||||
sections: [{ id: sectionId, kind: "empty", yOffset: 0, xOffset: 0 }],
|
||||
items: [
|
||||
{
|
||||
id: createId(),
|
||||
kind: "customApi",
|
||||
options: {
|
||||
definitionId: "definition-1",
|
||||
refreshInterval: 30,
|
||||
configuration: {},
|
||||
},
|
||||
integrationIds: [],
|
||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||
layouts: [
|
||||
{
|
||||
layoutId,
|
||||
sectionId,
|
||||
height: 1,
|
||||
width: 1,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
});
|
||||
|
||||
const expectInputToBeFullBoardWithName = (
|
||||
@@ -1543,7 +2057,8 @@ const createFullBoardAsync = async (db: Database, name: string) => {
|
||||
id: layoutId,
|
||||
name: "Base",
|
||||
columnCount: 10,
|
||||
breakpoint: 0,
|
||||
breakpoint: 768,
|
||||
role: "base",
|
||||
boardId,
|
||||
});
|
||||
|
||||
@@ -1623,19 +2138,21 @@ const addItemAsync = async (
|
||||
return itemId;
|
||||
};
|
||||
|
||||
const addDynamicSectionAsync = async (
|
||||
const addContainerAsync = async (
|
||||
db: Database,
|
||||
section: Partial<Pick<InferInsertModel<typeof sectionLayouts>, "xOffset" | "yOffset" | "width" | "height">> & {
|
||||
parentSectionId: string;
|
||||
boardId: string;
|
||||
layoutId: string;
|
||||
options?: Record<string, unknown>;
|
||||
},
|
||||
) => {
|
||||
const sectionId = createId();
|
||||
await db.insert(sections).values({
|
||||
id: sectionId,
|
||||
kind: "dynamic",
|
||||
kind: "container",
|
||||
boardId: section.boardId,
|
||||
options: SuperJSON.stringify(section.options ?? {}),
|
||||
});
|
||||
await db.insert(sectionLayouts).values({
|
||||
parentSectionId: section.parentSectionId,
|
||||
@@ -1677,7 +2194,7 @@ const createItemsAndSectionsAsync = async (
|
||||
const itemC = await addItemAsync(db, { boardId, layoutId, sectionId, xOffset: 6, width: 2, height: 2 });
|
||||
const itemD = await addItemAsync(db, { boardId, layoutId, sectionId, xOffset: 8, width: 2, height: 3 });
|
||||
const itemE = await addItemAsync(db, { boardId, layoutId, sectionId, yOffset: 1, height: 3 });
|
||||
const sectionF = await addDynamicSectionAsync(db, {
|
||||
const sectionF = await addContainerAsync(db, {
|
||||
yOffset: 1,
|
||||
xOffset: 1,
|
||||
width: 5,
|
||||
@@ -1686,7 +2203,7 @@ const createItemsAndSectionsAsync = async (
|
||||
boardId,
|
||||
layoutId,
|
||||
});
|
||||
const sectionG = await addDynamicSectionAsync(db, {
|
||||
const sectionG = await addContainerAsync(db, {
|
||||
yOffset: 2,
|
||||
xOffset: 6,
|
||||
width: 2,
|
||||
@@ -1717,7 +2234,7 @@ const createItemsAndSectionsAsync = async (
|
||||
f: sectionF,
|
||||
g: sectionG,
|
||||
},
|
||||
inDynamicSection: {
|
||||
inContainer: {
|
||||
h: itemH,
|
||||
i: itemI,
|
||||
j: itemJ,
|
||||
@@ -1753,7 +2270,7 @@ gg`,
|
||||
);
|
||||
};
|
||||
|
||||
const expectLayoutForDynamicSectionAsync = async (
|
||||
const expectLayoutForContainerAsync = async (
|
||||
db: Database,
|
||||
sectionId: string,
|
||||
layoutId: string,
|
||||
|
||||
@@ -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,551 @@
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { eq, sql } from "@homarr/db";
|
||||
import {
|
||||
customWidgetDefinitions,
|
||||
customWidgetSecrets,
|
||||
legacyCustomWidgetDefinitions,
|
||||
legacyCustomWidgetSecrets,
|
||||
users,
|
||||
} from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import { BUNDLED_CUSTOM_WIDGETS, customWidgetDefinitionSchema } 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";
|
||||
import { configureCustomWidgetSourceFromRequest } from "../../custom-widget/secret-persistence";
|
||||
import { serializeCustomWidgetDefinition } from "../../custom-widget/stored-definition";
|
||||
|
||||
const userId = createId();
|
||||
const session = {
|
||||
user: {
|
||||
id: userId,
|
||||
permissions: ["admin"],
|
||||
colorScheme: "light",
|
||||
},
|
||||
expires: new Date().toISOString(),
|
||||
} satisfies Session;
|
||||
const nonAdminSession = {
|
||||
...session,
|
||||
user: { ...session.user, permissions: ["board-modify-all"] },
|
||||
} satisfies Session;
|
||||
|
||||
const jellyfin = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-jellyfin")?.widget;
|
||||
if (!jellyfin) throw new Error("Jellyfin bundled widget is missing");
|
||||
const jellyfinDefaultSource = jellyfin.sources.default;
|
||||
if (!jellyfinDefaultSource) throw new Error("Jellyfin default source is missing");
|
||||
|
||||
function createCaller(db: ReturnType<typeof createDb>) {
|
||||
return customWidgetRouter.createCaller({ db, deviceType: undefined, session });
|
||||
}
|
||||
|
||||
function createNonAdminCaller(db: ReturnType<typeof createDb>) {
|
||||
return customWidgetRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: nonAdminSession,
|
||||
});
|
||||
}
|
||||
|
||||
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_v2_secret_insert
|
||||
BEFORE INSERT ON custom_widget_v2_secret
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced secret insert failure');
|
||||
END`),
|
||||
);
|
||||
}
|
||||
|
||||
function rejectDefinitionUpdates(db: ReturnType<typeof createDb>) {
|
||||
db.run(
|
||||
sql.raw(`CREATE TRIGGER reject_custom_widget_v2_definition_update
|
||||
BEFORE UPDATE ON custom_widget_v2_definition
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced definition update failure');
|
||||
END`),
|
||||
);
|
||||
}
|
||||
|
||||
async function insertLegacyJellyfin(db: ReturnType<typeof createDb>) {
|
||||
const id = "legacy-jellyfin";
|
||||
const encryptedValue = "preserved.secret" as `${string}.${string}`;
|
||||
await db.insert(legacyCustomWidgetDefinitions).values({
|
||||
id,
|
||||
name: "Legacy Jellyfin",
|
||||
url: "http://jellyfin.local/Items/Counts",
|
||||
authType: "apiKeyHeader",
|
||||
headerName: "X-Emby-Token",
|
||||
method: "GET",
|
||||
displayType: "singleValue",
|
||||
enabled: false,
|
||||
creatorId: userId,
|
||||
});
|
||||
await db.insert(legacyCustomWidgetSecrets).values({
|
||||
definitionId: id,
|
||||
kind: "apiKey",
|
||||
encryptedValue,
|
||||
updatedAt: new Date(0),
|
||||
});
|
||||
return { id, encryptedValue };
|
||||
}
|
||||
|
||||
const secret = { sourceId: "default", kind: "apiKey" as const, value: "test-secret" };
|
||||
|
||||
describe("custom widget definition persistence", () => {
|
||||
test("atomically creates a v2 replacement while preserving the legacy widget and its stable ID", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const legacy = await insertLegacyJellyfin(db);
|
||||
|
||||
await createCaller(db).migrateLegacy({ id: legacy.id, widget: jellyfin, secrets: [] });
|
||||
|
||||
expect(await db.query.legacyCustomWidgetDefinitions.findFirst()).toMatchObject({
|
||||
id: legacy.id,
|
||||
name: "Legacy Jellyfin",
|
||||
enabled: false,
|
||||
});
|
||||
expect(await db.query.legacyCustomWidgetSecrets.findFirst()).toMatchObject({
|
||||
definitionId: legacy.id,
|
||||
encryptedValue: legacy.encryptedValue,
|
||||
});
|
||||
expect(await db.query.customWidgetDefinitions.findFirst()).toMatchObject({
|
||||
id: legacy.id,
|
||||
enabled: false,
|
||||
creatorId: userId,
|
||||
});
|
||||
expect(await db.query.customWidgetSecrets.findFirst()).toMatchObject({
|
||||
definitionId: legacy.id,
|
||||
sourceId: "default",
|
||||
kind: "apiKey",
|
||||
encryptedValue: legacy.encryptedValue,
|
||||
});
|
||||
|
||||
const migratedList = await createCaller(db).list();
|
||||
expect(migratedList).toHaveLength(1);
|
||||
expect(migratedList[0]).toMatchObject({ id: legacy.id, migrationRequired: false });
|
||||
|
||||
await createCaller(db).delete({ id: legacy.id });
|
||||
|
||||
const fallbackList = await createCaller(db).list();
|
||||
expect(fallbackList).toHaveLength(1);
|
||||
expect(fallbackList[0]).toMatchObject({ id: legacy.id, migrationRequired: true });
|
||||
expect(await db.query.legacyCustomWidgetSecrets.findFirst()).toMatchObject({
|
||||
definitionId: legacy.id,
|
||||
encryptedValue: legacy.encryptedValue,
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps the legacy widget recoverable when confirmed replacement fails", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const legacy = await insertLegacyJellyfin(db);
|
||||
rejectSecretInserts(db);
|
||||
|
||||
await expect(createCaller(db).migrateLegacy({ id: legacy.id, widget: jellyfin, secrets: [] })).rejects.toThrow(
|
||||
"forced secret insert failure",
|
||||
);
|
||||
|
||||
expect(await db.query.legacyCustomWidgetDefinitions.findFirst()).toMatchObject({ id: legacy.id });
|
||||
expect(await db.query.legacyCustomWidgetSecrets.findFirst()).toMatchObject({
|
||||
definitionId: legacy.id,
|
||||
encryptedValue: legacy.encryptedValue,
|
||||
});
|
||||
expect(await db.query.customWidgetDefinitions.findMany()).toHaveLength(0);
|
||||
});
|
||||
|
||||
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("refuses to export a stored definition containing embedded credentials", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const definition = customWidgetDefinitionSchema.parse(jellyfin);
|
||||
const counts = definition.requests.counts;
|
||||
if (!counts) throw new Error("Jellyfin counts request is missing");
|
||||
const id = createId();
|
||||
await db.insert(customWidgetDefinitions).values({
|
||||
id,
|
||||
...serializeCustomWidgetDefinition({
|
||||
...definition,
|
||||
requests: {
|
||||
...definition.requests,
|
||||
counts: {
|
||||
...counts,
|
||||
headers: { ...counts.headers, "X-Auth": "Bearer sk-secret-123456" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
creatorId: userId,
|
||||
});
|
||||
|
||||
await expect(createCaller(db).export({ id })).rejects.toThrow("Credentials must use source authentication");
|
||||
});
|
||||
|
||||
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("keeps the existing secret when setting its replacement fails", 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.secretSet({
|
||||
definitionId: created.id,
|
||||
secret: { ...secret, value: "replacement-secret" },
|
||||
}),
|
||||
).rejects.toThrow("forced secret insert failure");
|
||||
|
||||
expect(
|
||||
await db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, created.id),
|
||||
}),
|
||||
).toEqual(originalDefinition);
|
||||
expect(
|
||||
await db.query.customWidgetSecrets.findFirst({
|
||||
where: eq(customWidgetSecrets.definitionId, created.id),
|
||||
}),
|
||||
).toEqual(originalSecret);
|
||||
});
|
||||
|
||||
test("keeps the existing secret when clearing it cannot update the definition revision", 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),
|
||||
});
|
||||
rejectDefinitionUpdates(db);
|
||||
|
||||
await expect(
|
||||
caller.secretClear({
|
||||
definitionId: created.id,
|
||||
sourceId: secret.sourceId,
|
||||
kind: secret.kind,
|
||||
}),
|
||||
).rejects.toThrow("forced definition update failure");
|
||||
|
||||
expect(
|
||||
await db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, created.id),
|
||||
}),
|
||||
).toEqual(originalDefinition);
|
||||
expect(
|
||||
await db.query.customWidgetSecrets.findFirst({
|
||||
where: eq(customWidgetSecrets.definitionId, created.id),
|
||||
}),
|
||||
).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",
|
||||
});
|
||||
});
|
||||
|
||||
test("does not inherit a stored credential after a preview source binding changes", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const created = await caller.create({ ...jellyfin, secrets: [secret] });
|
||||
const changedDefinition = {
|
||||
...jellyfin,
|
||||
sources: {
|
||||
...jellyfin.sources,
|
||||
default: { ...jellyfinDefaultSource, baseUrl: "https://attacker.example" },
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
createNonAdminCaller(db).previewCreate({
|
||||
definitionId: created.id,
|
||||
definition: changedDefinition,
|
||||
secrets: [],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
await expect(
|
||||
caller.previewCreate({ definitionId: created.id, definition: changedDefinition, secrets: [secret] }),
|
||||
).resolves.toMatchObject({ success: true });
|
||||
});
|
||||
|
||||
test("clears persisted credentials when an admin source edit changes its binding", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const created = await createCaller(db).create({ ...jellyfin, secrets: [secret] });
|
||||
|
||||
await createCaller(db).sourceConfigure({
|
||||
definitionId: created.id,
|
||||
sourceId: "default",
|
||||
baseUrl: "https://other.example/api",
|
||||
networkScope: "public",
|
||||
secrets: [],
|
||||
});
|
||||
|
||||
expect(
|
||||
await db.query.customWidgetSecrets.findMany({
|
||||
where: eq(customWidgetSecrets.definitionId, created.id),
|
||||
}),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("uses the persisted source binding from inside the configuration transaction", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const created = await createCaller(db).create({ ...jellyfin, secrets: [secret] });
|
||||
const definition = customWidgetDefinitionSchema.parse(jellyfin);
|
||||
const defaultSource = definition.sources.default;
|
||||
if (!defaultSource) throw new Error("Jellyfin default source is missing");
|
||||
await db
|
||||
.update(customWidgetDefinitions)
|
||||
.set(
|
||||
serializeCustomWidgetDefinition({
|
||||
...definition,
|
||||
name: "Concurrent definition edit",
|
||||
sources: {
|
||||
...definition.sources,
|
||||
default: { ...defaultSource, baseUrl: "https://changed.example/api" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
.where(eq(customWidgetDefinitions.id, created.id));
|
||||
|
||||
await expect(
|
||||
configureCustomWidgetSourceFromRequest(db, {
|
||||
definitionId: created.id,
|
||||
sourceId: "default",
|
||||
baseUrl: defaultSource.baseUrl,
|
||||
networkScope: defaultSource.networkScope,
|
||||
secrets: [],
|
||||
expectedSource: defaultSource,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
status: "configured",
|
||||
source: { baseUrl: defaultSource.baseUrl },
|
||||
});
|
||||
|
||||
expect(await createCaller(db).get({ id: created.id })).toMatchObject({
|
||||
name: "Concurrent definition edit",
|
||||
sources: { default: { baseUrl: defaultSource.baseUrl } },
|
||||
});
|
||||
expect(
|
||||
await db.query.customWidgetSecrets.findMany({
|
||||
where: eq(customWidgetSecrets.definitionId, created.id),
|
||||
}),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("rejects stale setup-link credentials after a concurrent authentication binding change", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const created = await createCaller(db).create({ ...jellyfin, secrets: [] });
|
||||
const definition = customWidgetDefinitionSchema.parse(jellyfin);
|
||||
const expectedSource = definition.sources.default;
|
||||
if (!expectedSource || typeof expectedSource.auth === "string") {
|
||||
throw new Error("Jellyfin default source must use header authentication");
|
||||
}
|
||||
await db
|
||||
.update(customWidgetDefinitions)
|
||||
.set(
|
||||
serializeCustomWidgetDefinition({
|
||||
...definition,
|
||||
name: "Concurrent definition edit",
|
||||
sources: {
|
||||
...definition.sources,
|
||||
default: {
|
||||
...expectedSource,
|
||||
auth: { ...expectedSource.auth, name: "X-Replaced-Credential" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
.where(eq(customWidgetDefinitions.id, created.id));
|
||||
|
||||
await expect(
|
||||
configureCustomWidgetSourceFromRequest(db, {
|
||||
definitionId: created.id,
|
||||
sourceId: "default",
|
||||
baseUrl: "https://configured.example/api",
|
||||
networkScope: "public",
|
||||
secrets: [secret],
|
||||
expectedSource,
|
||||
}),
|
||||
).resolves.toEqual({ status: "binding-changed" });
|
||||
|
||||
expect(await createCaller(db).get({ id: created.id })).toMatchObject({
|
||||
name: "Concurrent definition edit",
|
||||
sources: {
|
||||
default: {
|
||||
baseUrl: expectedSource.baseUrl,
|
||||
auth: { name: "X-Replaced-Credential" },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(
|
||||
await db.query.customWidgetSecrets.findMany({
|
||||
where: eq(customWidgetSecrets.definitionId, created.id),
|
||||
}),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("clears persisted credentials when a general definition update changes its binding", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const created = await caller.create({ ...jellyfin, secrets: [secret] });
|
||||
|
||||
await createCaller(db).update({
|
||||
id: created.id,
|
||||
sources: {
|
||||
...jellyfin.sources,
|
||||
default: { ...jellyfinDefaultSource, baseUrl: "https://other.example/api" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
await db.query.customWidgetSecrets.findMany({
|
||||
where: eq(customWidgetSecrets.definitionId, created.id),
|
||||
}),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("requires an administrator to create a stored-definition setup link", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const created = await createCaller(db).create({ ...jellyfin, secrets: [] });
|
||||
|
||||
await expect(
|
||||
createNonAdminCaller(db).configurationRequestUser({ definitionId: created.id, sourceId: "default" }),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
|
||||
test("allows only one concurrent template patch for a revision", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const created = await caller.create({ ...jellyfin, template: "<Text>Original</Text>", secrets: [] });
|
||||
const read = await caller.readTemplate({ id: created.id });
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
caller.templatePatch({
|
||||
id: created.id,
|
||||
expectedRevision: read.revision,
|
||||
edits: [{ startLine: 1, deleteCount: 1, replacementLines: ["<Text>First</Text>"] }],
|
||||
}),
|
||||
caller.templatePatch({
|
||||
id: created.id,
|
||||
expectedRevision: read.revision,
|
||||
edits: [{ startLine: 1, deleteCount: 1, replacementLines: ["<Text>Second</Text>"] }],
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(results.filter(({ status }) => status === "fulfilled")).toHaveLength(1);
|
||||
expect(results.filter(({ status }) => status === "rejected")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { stringify as stringifySuperJson } from "superjson";
|
||||
|
||||
import { buildLegacyCustomWidgetMigrationPrompt } from "../../custom-widget/legacy-migration";
|
||||
|
||||
describe("legacy custom widget migration prompt", () => {
|
||||
test("describes the legacy widget without exposing URL or body credentials", () => {
|
||||
const prompt = buildLegacyCustomWidgetMigrationPrompt(
|
||||
{
|
||||
id: "legacy",
|
||||
name: "Private service",
|
||||
description: "Status",
|
||||
iconUrl: "https://icons:password@example.test/private/icon.svg?access_token=icon-secret",
|
||||
url: "https://admin:password@example.test/hooks/path-token?api_key=top-secret&mode=full",
|
||||
authType: "bearer",
|
||||
headerName: null,
|
||||
method: "POST",
|
||||
requestBody: JSON.stringify({ token: "body-secret", limit: 5 }),
|
||||
displayType: "singleValue",
|
||||
displayConfig: "corrupted-config-with-display-secret",
|
||||
enabled: true,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
creatorId: null,
|
||||
},
|
||||
["apiKey"],
|
||||
);
|
||||
|
||||
expect(prompt).toContain("homarr-custom-widget-v1");
|
||||
expect(prompt).toContain("homarr-custom-widget-v2");
|
||||
expect(prompt).toContain("configuredSecretKinds");
|
||||
expect(prompt).not.toContain("top-secret");
|
||||
expect(prompt).not.toContain("body-secret");
|
||||
expect(prompt).not.toContain("admin:password");
|
||||
expect(prompt).not.toContain("path-token");
|
||||
expect(prompt).not.toContain("icon-secret");
|
||||
expect(prompt).not.toContain("icons:password");
|
||||
expect(prompt).not.toContain("private/icon.svg");
|
||||
expect(prompt).not.toContain("display-secret");
|
||||
});
|
||||
|
||||
test("omits valid free-text display configuration from the migration prompt", () => {
|
||||
const prompt = buildLegacyCustomWidgetMigrationPrompt(
|
||||
{
|
||||
id: "legacy-custom-jsx",
|
||||
name: "Private service",
|
||||
description: "Status",
|
||||
iconUrl: null,
|
||||
url: "https://example.test/api/status",
|
||||
authType: "bearer",
|
||||
headerName: null,
|
||||
method: "GET",
|
||||
requestBody: null,
|
||||
displayType: "customJsx",
|
||||
displayConfig: stringifySuperJson({
|
||||
type: "customJsx",
|
||||
template: "<Text>LEGACY_PRIVATE_MARKER Bearer sk-secret-123456</Text>",
|
||||
}),
|
||||
enabled: true,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
creatorId: null,
|
||||
},
|
||||
["apiKey"],
|
||||
);
|
||||
|
||||
expect(prompt).toContain("display configuration was intentionally omitted");
|
||||
expect(prompt).not.toContain("LEGACY_PRIVATE_MARKER");
|
||||
expect(prompt).not.toContain("Bearer sk-secret-123456");
|
||||
expect(prompt).not.toContain("sk-secret-123456");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { resolvePreviewRequestParams } from "../../custom-widget/preview-procedure-helpers";
|
||||
import { resolveOptionRequestValues } from "../../custom-widget/management-queries";
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("configuration option request bindings", () => {
|
||||
it("resolves option configuration and runtime parameters in their own slots", () => {
|
||||
const request: CustomJsxRequest = {
|
||||
...loadRequest,
|
||||
path: "/environments/{option:environmentId}/containers/{param:containerId}",
|
||||
};
|
||||
|
||||
expect(resolveOptionRequestValues(request, { environmentId: 7, containerId: 12, unrelated: true })).toEqual({
|
||||
"option:environmentId": 7,
|
||||
containerId: 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,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,113 @@
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
get: vi.fn(async (_id: string): Promise<unknown> => undefined),
|
||||
list: vi.fn(async (_options: unknown): Promise<unknown> => undefined),
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/logs", () => ({
|
||||
createLogger: () => mocks.logger,
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/workshop/backend", () => ({
|
||||
WorkshopBackend: class {
|
||||
public get(id: string) {
|
||||
return mocks.get(id);
|
||||
}
|
||||
|
||||
public list(options: unknown) {
|
||||
return mocks.list(options);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { customWidgetRouter } from "../../custom-widget/custom-widget-router";
|
||||
|
||||
const session = {
|
||||
user: {
|
||||
id: createId(),
|
||||
permissions: ["admin"],
|
||||
colorScheme: "light",
|
||||
},
|
||||
expires: new Date(Date.now() + 60_000).toISOString(),
|
||||
} satisfies Session;
|
||||
|
||||
const createCaller = () =>
|
||||
customWidgetRouter.createCaller({
|
||||
db: createDb(),
|
||||
deviceType: undefined,
|
||||
session,
|
||||
});
|
||||
|
||||
const createTokenizedFailure = (apiKey: string) => {
|
||||
const tokenizedUrl = `https://workshop.example/api/submissions?access_token=${apiKey}`;
|
||||
const error = new Error(`Workshop request failed for ${tokenizedUrl}`);
|
||||
error.name = `WorkshopTransportError:${tokenizedUrl}`;
|
||||
return error;
|
||||
};
|
||||
|
||||
const expectSafeFailure = (
|
||||
error: unknown,
|
||||
apiKey: string,
|
||||
logMessage: string,
|
||||
event: "workshop_widget_lookup_failed" | "workshop_widget_search_failed",
|
||||
) => {
|
||||
expect(error).toMatchObject({
|
||||
code: "BAD_GATEWAY",
|
||||
message: "Workshop is unavailable",
|
||||
});
|
||||
expect((error as Error).cause).toBeUndefined();
|
||||
expect(String(error)).not.toContain(apiKey);
|
||||
expect(String(error)).not.toContain("access_token");
|
||||
expect(mocks.logger.error).toHaveBeenCalledWith(logMessage, {
|
||||
event,
|
||||
errorName: "WorkshopBackendError",
|
||||
});
|
||||
expect(JSON.stringify(mocks.logger.error.mock.calls)).not.toContain(apiKey);
|
||||
expect(JSON.stringify(mocks.logger.error.mock.calls)).not.toContain("access_token");
|
||||
};
|
||||
|
||||
describe("Workshop backend error privacy", () => {
|
||||
beforeEach(() => {
|
||||
mocks.get.mockReset();
|
||||
mocks.list.mockReset();
|
||||
mocks.logger.error.mockClear();
|
||||
});
|
||||
|
||||
test("redacts backend lookup failures from TRPC output and logs", async () => {
|
||||
const apiKey = "workshop-lookup-secret";
|
||||
mocks.get.mockRejectedValueOnce(createTokenizedFailure(apiKey));
|
||||
|
||||
const error = await createCaller()
|
||||
.workshopGet({ submissionId: "submission-id" })
|
||||
.then(
|
||||
() => undefined,
|
||||
(cause: unknown) => cause,
|
||||
);
|
||||
|
||||
expectSafeFailure(error, apiKey, "Workshop widget lookup failed", "workshop_widget_lookup_failed");
|
||||
});
|
||||
|
||||
test("redacts backend search failures from TRPC output and logs", async () => {
|
||||
const apiKey = "workshop-search-secret";
|
||||
mocks.list.mockRejectedValueOnce(createTokenizedFailure(apiKey));
|
||||
|
||||
const error = await createCaller()
|
||||
.workshopSearch({ query: "weather" })
|
||||
.then(
|
||||
() => undefined,
|
||||
(cause: unknown) => cause,
|
||||
);
|
||||
|
||||
expectSafeFailure(error, apiKey, "Workshop widget search failed", "workshop_widget_search_failed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { locationRouter } from "../location";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ fetch: vi.fn() }));
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: mocks.fetch,
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http/timeout", () => ({
|
||||
withTimeoutAsync: async (callback: (signal: AbortSignal) => Promise<Response>) =>
|
||||
await callback(new AbortController().signal),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.fetch.mockResolvedValue(new Response(JSON.stringify({ results: [] })));
|
||||
});
|
||||
|
||||
describe("locationRouter", () => {
|
||||
test("encodes the complete search text as one name parameter", async () => {
|
||||
const caller = locationRouter.createCaller({ db: createDb(), deviceType: undefined, session: null });
|
||||
|
||||
await caller.searchCity({ query: "Paris&language=xx&token=secret" });
|
||||
|
||||
const requestedUrl = new URL(mocks.fetch.mock.calls[0]?.[0] as string);
|
||||
expect(requestedUrl.searchParams.get("name")).toBe("Paris&language=xx&token=secret");
|
||||
expect(requestedUrl.searchParams.has("language")).toBe(false);
|
||||
expect(requestedUrl.searchParams.has("token")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { stringify } from "superjson";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { eq } from "@homarr/db";
|
||||
import { boards, sectionCollapseStates, sections, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { sectionRouter } from "../section/section-router";
|
||||
|
||||
const userId = createId();
|
||||
const session = {
|
||||
user: {
|
||||
id: userId,
|
||||
permissions: [],
|
||||
colorScheme: "light",
|
||||
},
|
||||
expires: new Date().toISOString(),
|
||||
} satisfies Session;
|
||||
|
||||
const createCallerAsync = async () => {
|
||||
const db = createDb();
|
||||
const boardId = createId();
|
||||
|
||||
await db.insert(users).values({ id: userId });
|
||||
await db.insert(boards).values({
|
||||
id: boardId,
|
||||
name: createId(),
|
||||
creatorId: userId,
|
||||
});
|
||||
|
||||
return {
|
||||
boardId,
|
||||
db,
|
||||
caller: sectionRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
describe("changeCollapsed", () => {
|
||||
it("rejects a root section", async () => {
|
||||
const { boardId, caller, db } = await createCallerAsync();
|
||||
const sectionId = createId();
|
||||
await db.insert(sections).values({
|
||||
id: sectionId,
|
||||
boardId,
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
});
|
||||
|
||||
await expect(caller.changeCollapsed({ sectionId, collapsed: true })).rejects.toThrow("Section cannot be collapsed");
|
||||
});
|
||||
|
||||
it("creates and updates collapse state for an enabled container", async () => {
|
||||
const { boardId, caller, db } = await createCallerAsync();
|
||||
const sectionId = createId();
|
||||
await db.insert(sections).values({
|
||||
id: sectionId,
|
||||
boardId,
|
||||
kind: "container",
|
||||
options: stringify({ collapsible: true }),
|
||||
});
|
||||
|
||||
await caller.changeCollapsed({ sectionId, collapsed: true });
|
||||
await caller.changeCollapsed({ sectionId, collapsed: false });
|
||||
|
||||
const states = await db.query.sectionCollapseStates.findMany({
|
||||
where: eq(sectionCollapseStates.sectionId, sectionId),
|
||||
});
|
||||
expect(states).toHaveLength(1);
|
||||
expect(states[0]?.collapsed).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a container when collapsing is disabled", async () => {
|
||||
const { boardId, caller, db } = await createCallerAsync();
|
||||
const sectionId = createId();
|
||||
await db.insert(sections).values({
|
||||
id: sectionId,
|
||||
boardId,
|
||||
kind: "container",
|
||||
});
|
||||
|
||||
await expect(caller.changeCollapsed({ sectionId, collapsed: true })).rejects.toThrow("Section cannot be collapsed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import type { Database } from "@homarr/db";
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
|
||||
import { updateCheckerRouter } from "../update-checker";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getDataAsync: vi.fn(async () => ({
|
||||
data: { availableUpdates: [{ tagName: "v99.0.0" }] },
|
||||
timestamp: new Date(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/update-checker", () => ({
|
||||
updateCheckerRequestHandler: {
|
||||
handler: () => ({ getDataAsync: mocks.getDataAsync }),
|
||||
},
|
||||
}));
|
||||
|
||||
const createSession = (permissions: GroupPermissionKey[]): Session => ({
|
||||
user: {
|
||||
id: "user",
|
||||
permissions,
|
||||
colorScheme: "light",
|
||||
},
|
||||
expires: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const createCaller = (session: Session | null) =>
|
||||
updateCheckerRouter.createCaller({
|
||||
db: null as unknown as Database,
|
||||
deviceType: undefined,
|
||||
session,
|
||||
});
|
||||
|
||||
describe("update checker authorization", () => {
|
||||
test("allows administrators to read the cached result", async () => {
|
||||
await expect(createCaller(createSession(["admin"])).getAvailableUpdates()).resolves.toEqual([
|
||||
{ tagName: "v99.0.0" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("rejects authenticated users without admin permission", async () => {
|
||||
await expect(createCaller(createSession([])).getAvailableUpdates()).rejects.toThrow("Permission denied");
|
||||
});
|
||||
|
||||
test("rejects anonymous users", async () => {
|
||||
await expect(createCaller(null).getAvailableUpdates()).rejects.toThrow("UNAUTHORIZED");
|
||||
});
|
||||
|
||||
test("returns an empty result when the cache reader fails", async () => {
|
||||
mocks.getDataAsync.mockRejectedValueOnce(new Error("Redis unavailable"));
|
||||
|
||||
await expect(createCaller(createSession(["admin"])).getAvailableUpdates()).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { integrations, integrationUserPermissions, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { immichRouter } from "../../widgets/immich";
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.SKIP_ENV_VALIDATION = "true";
|
||||
});
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
albumsHandler: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/immich", () => ({
|
||||
immichAlbumRequestHandler: { handler: vi.fn() },
|
||||
immichStatsRequestHandler: { handler: vi.fn() },
|
||||
immichAlbumsRequestHandler: {
|
||||
handler: (_integration: unknown, input: unknown) => {
|
||||
mocks.albumsHandler(input);
|
||||
return { getDataAsync: async () => ({ data: [], timestamp: new Date() }) };
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const createSession = (userId: string): Session => ({
|
||||
user: { id: userId, permissions: [], colorScheme: "light" },
|
||||
expires: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const createCallerAsync = async () => {
|
||||
const db = createDb();
|
||||
const userId = createId();
|
||||
const integrationId = createId();
|
||||
await db.insert(users).values({ id: userId });
|
||||
await db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
kind: "immich",
|
||||
name: "Immich",
|
||||
url: "https://immich.example.com",
|
||||
});
|
||||
await db.insert(integrationUserPermissions).values({ integrationId, userId, permission: "use" });
|
||||
|
||||
return {
|
||||
integrationId,
|
||||
caller: immichRouter.createCaller({ db: db as Database, deviceType: undefined, session: createSession(userId) }),
|
||||
};
|
||||
};
|
||||
|
||||
describe("immichRouter.getAlbums", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
test("passes a validated limit to the cached request handler", async () => {
|
||||
const { caller, integrationId } = await createCallerAsync();
|
||||
|
||||
await caller.getAlbums({ integrationId, limit: 50 });
|
||||
|
||||
expect(mocks.albumsHandler).toHaveBeenCalledWith({ limit: 50 });
|
||||
await expect(caller.getAlbums({ integrationId, limit: 0 })).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
await expect(caller.getAlbums({ integrationId, limit: 501 })).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
test("keeps the limit optional for unbounded callers", async () => {
|
||||
const { caller, integrationId } = await createCallerAsync();
|
||||
|
||||
await caller.getAlbums({ integrationId });
|
||||
|
||||
expect(mocks.albumsHandler).toHaveBeenCalledWith({ limit: undefined });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import SuperJSON from "superjson";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { eq } from "@homarr/db";
|
||||
import { boards, items, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { optionsRouter } from "../../widgets/options";
|
||||
|
||||
const createSession = (userId: string, isAdmin = false): Session => ({
|
||||
user: {
|
||||
id: userId,
|
||||
permissions: isAdmin ? ["admin"] : [],
|
||||
colorScheme: "light",
|
||||
},
|
||||
expires: new Date(Date.now() + 60_000).toISOString(),
|
||||
});
|
||||
|
||||
async function setup(kind: "customApi" | "weather") {
|
||||
const db = createDb();
|
||||
const userId = createId();
|
||||
const boardId = createId();
|
||||
const itemId = createId();
|
||||
await db.insert(users).values({ id: userId });
|
||||
await db.insert(boards).values({ id: boardId, name: createId(), creatorId: userId });
|
||||
await db.insert(items).values({
|
||||
id: itemId,
|
||||
boardId,
|
||||
kind,
|
||||
options: SuperJSON.stringify(
|
||||
kind === "customApi"
|
||||
? { definitionId: "definition-1", refreshInterval: 30, configuration: { server: "primary" } }
|
||||
: { location: "Paris" },
|
||||
),
|
||||
});
|
||||
return { db, userId, boardId, itemId };
|
||||
}
|
||||
|
||||
describe("saveItemOptions Custom Widget access", () => {
|
||||
test("rejects direct Custom Widget reconfiguration by a non-admin board modifier", async () => {
|
||||
const { db, userId, boardId, itemId } = await setup("customApi");
|
||||
const caller = optionsRouter.createCaller({ db, deviceType: undefined, session: createSession(userId) });
|
||||
|
||||
await expect(
|
||||
caller.saveItemOptions({
|
||||
boardId,
|
||||
itemId,
|
||||
newOptions: {
|
||||
definitionId: "definition-2",
|
||||
refreshInterval: 1,
|
||||
configuration: { server: "attacker" },
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
|
||||
const stored = await db.query.items.findFirst({ where: eq(items.id, itemId) });
|
||||
expect(SuperJSON.parse(stored?.options ?? "")).toMatchObject({
|
||||
definitionId: "definition-1",
|
||||
refreshInterval: 30,
|
||||
configuration: { server: "primary" },
|
||||
});
|
||||
});
|
||||
|
||||
test("allows a Homarr admin to reconfigure a Custom Widget placement", async () => {
|
||||
const { db, userId, boardId, itemId } = await setup("customApi");
|
||||
const caller = optionsRouter.createCaller({ db, deviceType: undefined, session: createSession(userId, true) });
|
||||
|
||||
await expect(
|
||||
caller.saveItemOptions({ boardId, itemId, newOptions: { refreshInterval: 60 } }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const stored = await db.query.items.findFirst({ where: eq(items.id, itemId) });
|
||||
expect(SuperJSON.parse(stored?.options ?? "")).toMatchObject({ refreshInterval: 60 });
|
||||
});
|
||||
|
||||
test("allows normalized missing defaults without treating them as reconfiguration", async () => {
|
||||
const { db, userId, boardId, itemId } = await setup("customApi");
|
||||
const stored = await db.query.items.findFirst({ where: eq(items.id, itemId) });
|
||||
const options = SuperJSON.parse<Record<string, unknown>>(stored?.options ?? "");
|
||||
delete options.configuration;
|
||||
delete options.configurationVersion;
|
||||
await db
|
||||
.update(items)
|
||||
.set({ options: SuperJSON.stringify(options) })
|
||||
.where(eq(items.id, itemId));
|
||||
const caller = optionsRouter.createCaller({ db, deviceType: undefined, session: createSession(userId) });
|
||||
|
||||
await expect(
|
||||
caller.saveItemOptions({
|
||||
boardId,
|
||||
itemId,
|
||||
newOptions: { configuration: {}, configurationVersion: 1 },
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("preserves ordinary board-modifier option updates for other widgets", async () => {
|
||||
const { db, userId, boardId, itemId } = await setup("weather");
|
||||
const caller = optionsRouter.createCaller({ db, deviceType: undefined, session: createSession(userId) });
|
||||
|
||||
await expect(
|
||||
caller.saveItemOptions({ boardId, itemId, newOptions: { location: "Lyon" } }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { integrations, integrationUserPermissions, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import type { MediaRequest, MediaRequestStats } from "@homarr/integrations/types";
|
||||
import type * as MediaRequestListHandlerModule from "@homarr/request-handler/media-request-list";
|
||||
|
||||
import { calendarRouter } from "../../widgets/calendar";
|
||||
import { beszelRouter } from "../../widgets/beszel";
|
||||
import { firewallRouter } from "../../widgets/firewall";
|
||||
import { indexerManagerRouter } from "../../widgets/indexer-manager";
|
||||
import { mediaOrganizerRouter } from "../../widgets/media-organizer";
|
||||
import { mediaRequestsRouter } from "../../widgets/media-requests";
|
||||
import { mediaServerRouter } from "../../widgets/media-server";
|
||||
import { notificationsRouter } from "../../widgets/notifications";
|
||||
import { vpnRouter } from "../../widgets/vpn";
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.SKIP_ENV_VALIDATION = "true";
|
||||
});
|
||||
|
||||
const createHandler = <T>(integration: { name: string }, data: T) => ({
|
||||
getDataAsync: async () => {
|
||||
if (integration.name === "Offline") {
|
||||
throw new Error(
|
||||
"GET http://admin:password@internal.example/private/path?token=secret returned\nprivate response body",
|
||||
);
|
||||
}
|
||||
return { data, timestamp: new Date() };
|
||||
},
|
||||
});
|
||||
|
||||
vi.mock("@homarr/request-handler/calendar", () => ({
|
||||
calendarMonthRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) },
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/media-server", () => ({
|
||||
mediaServerRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) },
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/media-organizer", () => ({
|
||||
mediaOrganizerRequestHandler: {
|
||||
handler: (integration: { name: string }) =>
|
||||
createHandler(integration, { missing: [], missingCount: 0, queued: [], queuedCount: 0 }),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/beszel", () => ({
|
||||
beszelSystemsRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) },
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/firewall", () => ({
|
||||
firewallCpuRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, { total: 1 }) },
|
||||
firewallInterfacesRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) },
|
||||
firewallMemoryRequestHandler: {
|
||||
handler: (integration: { name: string }) => createHandler(integration, { used: 1, total: 2, percent: 50 }),
|
||||
},
|
||||
firewallVersionRequestHandler: {
|
||||
handler: (integration: { name: string }) => createHandler(integration, { version: "1.0" }),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/indexer-manager", () => ({
|
||||
indexerManagerRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) },
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/notifications", () => ({
|
||||
notificationsRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) },
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/vpn", () => ({
|
||||
vpnSummaryHandler: { handler: (integration: { name: string }) => createHandler(integration, { connected: true }) },
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/media-request-list", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof MediaRequestListHandlerModule>();
|
||||
return {
|
||||
...actual,
|
||||
mediaRequestListRequestHandler: {
|
||||
handler: (integration: { name: string }) =>
|
||||
createHandler(integration, [
|
||||
{
|
||||
id: 42,
|
||||
name: "Healthy request",
|
||||
type: "movie",
|
||||
backdropImageUrl: "https://example.com/backdrop.jpg",
|
||||
posterImagePath: "https://example.com/poster.jpg",
|
||||
href: "https://example.com/request/42",
|
||||
createdAt: new Date("2026-08-01T12:00:00Z"),
|
||||
status: "pending",
|
||||
availability: "requested",
|
||||
} satisfies MediaRequest,
|
||||
]),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@homarr/request-handler/media-request-stats", () => ({
|
||||
mediaRequestStatsRequestHandler: {
|
||||
handler: (integration: { name: string }) =>
|
||||
createHandler(integration, {
|
||||
stats: { total: 0, movie: 0, tv: 0, pending: 0, approved: 0, declined: 0, processing: 0, available: 0 },
|
||||
users: [
|
||||
{
|
||||
id: 7,
|
||||
displayName: "Healthy user",
|
||||
avatar: "https://example.com/avatar.jpg",
|
||||
requestCount: 3,
|
||||
link: "https://example.com/user/7",
|
||||
},
|
||||
],
|
||||
} satisfies MediaRequestStats),
|
||||
},
|
||||
}));
|
||||
|
||||
const createSession = (userId: string): Session => ({
|
||||
user: { id: userId, permissions: [], colorScheme: "light" },
|
||||
expires: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const setupAsync = async (kind: IntegrationKind) => {
|
||||
const db = createDb();
|
||||
const userId = createId();
|
||||
const onlineId = createId();
|
||||
const offlineId = createId();
|
||||
await db.insert(users).values({ id: userId });
|
||||
await db.insert(integrations).values([
|
||||
{ id: onlineId, kind, name: "Online", url: "https://online.example.com" },
|
||||
{ id: offlineId, kind, name: "Offline", url: "https://offline.example.com" },
|
||||
]);
|
||||
await db.insert(integrationUserPermissions).values([
|
||||
{ integrationId: onlineId, userId, permission: "use" },
|
||||
{ integrationId: offlineId, userId, permission: "use" },
|
||||
]);
|
||||
return {
|
||||
db: db as Database,
|
||||
session: createSession(userId),
|
||||
integrationIds: [onlineId, offlineId],
|
||||
onlineId,
|
||||
offlineId,
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("partial integration failures", () => {
|
||||
test("calendar and media server retain a failed integration record", async () => {
|
||||
const calendar = await setupAsync("sonarr");
|
||||
const calendarResults = await calendarRouter
|
||||
.createCaller({ db: calendar.db, deviceType: undefined, session: calendar.session })
|
||||
.findAllEvents({
|
||||
integrationIds: calendar.integrationIds,
|
||||
year: 2026,
|
||||
month: 7,
|
||||
releaseType: [],
|
||||
showUnmonitored: false,
|
||||
});
|
||||
expect(calendarResults).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
integration: expect.objectContaining({ id: calendar.offlineId }),
|
||||
error: "INTEGRATION_REQUEST_FAILED",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(JSON.stringify(calendarResults)).not.toContain("secret");
|
||||
|
||||
const mediaServer = await setupAsync("plex");
|
||||
const streamResults = await mediaServerRouter
|
||||
.createCaller({ db: mediaServer.db, deviceType: undefined, session: mediaServer.session })
|
||||
.getCurrentStreams({ integrationIds: mediaServer.integrationIds, showOnlyPlaying: false });
|
||||
expect(streamResults).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
integrationId: mediaServer.offlineId,
|
||||
sessions: [],
|
||||
error: "INTEGRATION_REQUEST_FAILED",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(JSON.stringify(streamResults)).not.toContain("secret");
|
||||
});
|
||||
|
||||
test("media request responses expose failed integrations without dropping successful data", async () => {
|
||||
const mediaRequests = await setupAsync("overseerr");
|
||||
const caller = mediaRequestsRouter.createCaller({
|
||||
db: mediaRequests.db,
|
||||
deviceType: undefined,
|
||||
session: mediaRequests.session,
|
||||
});
|
||||
|
||||
const latest = await caller.getLatestRequests({
|
||||
integrationIds: mediaRequests.integrationIds,
|
||||
statuses: ["pending"],
|
||||
recentDays: 0,
|
||||
});
|
||||
expect(latest.requests).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 42,
|
||||
name: "Healthy request",
|
||||
integrationId: mediaRequests.onlineId,
|
||||
integration: expect.objectContaining({ id: mediaRequests.onlineId }),
|
||||
}),
|
||||
]);
|
||||
expect(latest.failedIntegrations).toEqual([
|
||||
expect.objectContaining({ integrationId: mediaRequests.offlineId, error: "INTEGRATION_REQUEST_FAILED" }),
|
||||
]);
|
||||
expect(JSON.stringify(latest)).not.toContain("secret");
|
||||
|
||||
const stats = await caller.getStats({ integrationIds: mediaRequests.integrationIds });
|
||||
expect(stats.failedIntegrations).toEqual([
|
||||
expect.objectContaining({ integrationId: mediaRequests.offlineId, error: "INTEGRATION_REQUEST_FAILED" }),
|
||||
]);
|
||||
expect(JSON.stringify(stats)).not.toContain("secret");
|
||||
expect(stats.stats).toHaveLength(1);
|
||||
expect(stats.users).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 7,
|
||||
displayName: "Healthy user",
|
||||
integration: expect.objectContaining({ id: mediaRequests.onlineId }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("aggregate widget payloads never expose sensitive partial-failure details", async () => {
|
||||
const cases = [
|
||||
{
|
||||
kind: "sonarr" as const,
|
||||
run: async (setup: Awaited<ReturnType<typeof setupAsync>>) =>
|
||||
await mediaOrganizerRouter
|
||||
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
|
||||
.getData({ integrationIds: setup.integrationIds, pageSize: 10 }),
|
||||
},
|
||||
{
|
||||
kind: "beszel" as const,
|
||||
run: async (setup: Awaited<ReturnType<typeof setupAsync>>) =>
|
||||
await beszelRouter
|
||||
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
|
||||
.getSystems({ integrationIds: setup.integrationIds }),
|
||||
},
|
||||
{
|
||||
kind: "opnsense" as const,
|
||||
run: async (setup: Awaited<ReturnType<typeof setupAsync>>) =>
|
||||
await firewallRouter
|
||||
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
|
||||
.getFirewallCpuStatus({ integrationIds: setup.integrationIds }),
|
||||
},
|
||||
{
|
||||
kind: "prowlarr" as const,
|
||||
run: async (setup: Awaited<ReturnType<typeof setupAsync>>) =>
|
||||
await indexerManagerRouter
|
||||
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
|
||||
.getIndexersStatus({ integrationIds: setup.integrationIds }),
|
||||
},
|
||||
{
|
||||
kind: "ntfy" as const,
|
||||
run: async (setup: Awaited<ReturnType<typeof setupAsync>>) =>
|
||||
await notificationsRouter
|
||||
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
|
||||
.getNotifications({ integrationIds: setup.integrationIds }),
|
||||
},
|
||||
{
|
||||
kind: "gluetun" as const,
|
||||
run: async (setup: Awaited<ReturnType<typeof setupAsync>>) =>
|
||||
await vpnRouter
|
||||
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
|
||||
.getSummaries({ integrationIds: setup.integrationIds }),
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const setup = await setupAsync(testCase.kind);
|
||||
const payload = JSON.stringify(await testCase.run(setup));
|
||||
expect(payload).toContain("INTEGRATION_REQUEST_FAILED");
|
||||
expect(payload).not.toMatch(/internal\.example|token=secret|\/private\/|response body|password/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -101,9 +101,7 @@ describe("patchmonRouter.getStats access control", () => {
|
||||
});
|
||||
const caller = createCaller(db, null);
|
||||
|
||||
await expect(caller.getStats({ integrationId })).rejects.toThrow(
|
||||
new TRPCError({ code: "UNAUTHORIZED" }),
|
||||
);
|
||||
await expect(caller.getStats({ integrationId })).rejects.toThrow(new TRPCError({ code: "UNAUTHORIZED" }));
|
||||
});
|
||||
|
||||
test("should throw FORBIDDEN for authenticated users without board or integration access", async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import SuperJSON from "superjson";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
@@ -17,7 +17,18 @@ import { createDb } from "@homarr/db/test";
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
|
||||
import type { WidgetComponentProps } from "../../../../../widgets/src/definition";
|
||||
import { canAccessAllFeedsAsync, restrictUrlsAsync } from "../../widgets/rssFeed";
|
||||
import { canAccessAllFeedsAsync, restrictUrlsAsync, rssFeedRouter } from "../../widgets/rssFeed";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ logger: { warn: vi.fn() }, getFeed: vi.fn() }));
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/logs", () => ({ createLogger: () => mocks.logger }));
|
||||
vi.mock("@homarr/request-handler/rss-feeds", () => ({
|
||||
rssFeedsRequestHandler: {
|
||||
handler: (input: unknown) => ({ getDataAsync: async () => await mocks.getFeed(input) }),
|
||||
},
|
||||
}));
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
const createSession = (permissions: GroupPermissionKey[] = []): Session => ({
|
||||
user: {
|
||||
@@ -29,6 +40,53 @@ const createSession = (permissions: GroupPermissionKey[] = []): Session => ({
|
||||
});
|
||||
|
||||
describe("rssFeedRouter", () => {
|
||||
describe("getFeeds", () => {
|
||||
test("redacts feed secrets from logs and returns a generic error when every feed fails", async () => {
|
||||
const feedUrl = "https://user:password@example.com/feed?token=secret";
|
||||
mocks.getFeed.mockRejectedValue(new Error(`Failed to fetch ${feedUrl}`));
|
||||
const caller = rssFeedRouter.createCaller({
|
||||
db: createDb(),
|
||||
deviceType: undefined,
|
||||
session: createSession(["board-create"]),
|
||||
});
|
||||
|
||||
await expect(caller.getFeeds({ urls: [feedUrl], maximumAmountPosts: 5 })).rejects.toMatchObject({
|
||||
code: "BAD_GATEWAY",
|
||||
message: "All RSS feed requests failed",
|
||||
});
|
||||
expect(mocks.logger.warn).toHaveBeenCalledWith("RSS feed fetch failed", {
|
||||
feedIndex: 0,
|
||||
origin: "https://example.com",
|
||||
errorType: "Error",
|
||||
errorMessage: "Failed to fetch [redacted URL]",
|
||||
});
|
||||
const logged = JSON.stringify(mocks.logger.warn.mock.calls);
|
||||
expect(logged).not.toContain("password");
|
||||
expect(logged).not.toContain("token");
|
||||
expect(logged).not.toContain("secret");
|
||||
});
|
||||
|
||||
test("returns successful entries and only a failure count when some feeds fail", async () => {
|
||||
const failedUrl = "https://user:password@example.com/feed?token=secret";
|
||||
mocks.getFeed.mockImplementation(async (input: { url: string }) => {
|
||||
if (input.url === failedUrl) throw new Error(`Failed to fetch ${failedUrl}`);
|
||||
return { data: { entries: [{ id: "entry" }] } };
|
||||
});
|
||||
const caller = rssFeedRouter.createCaller({
|
||||
db: createDb(),
|
||||
deviceType: undefined,
|
||||
session: createSession(["board-create"]),
|
||||
});
|
||||
|
||||
const result = await caller.getFeeds({ urls: ["https://example.com/feed", failedUrl], maximumAmountPosts: 5 });
|
||||
|
||||
expect(result).toEqual({ entries: [{ id: "entry" }], failedFeedCount: 1 });
|
||||
expect(JSON.stringify(result)).not.toContain("password");
|
||||
expect(JSON.stringify(result)).not.toContain("token");
|
||||
expect(JSON.stringify(result)).not.toContain("secret");
|
||||
});
|
||||
});
|
||||
|
||||
describe("canAccessAllFeedsAsync", () => {
|
||||
test("should return false for unauthenticated users", async () => {
|
||||
// Arrange
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { parse, stringify } from "superjson";
|
||||
import { 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, items, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { boardRouter } from "../../board";
|
||||
import { optionsRouter } from "../../widgets/options";
|
||||
import { timetableRouter } from "../../widgets/timetable";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ getTimetable: vi.fn(), searchStations: vi.fn(), lookup: vi.fn() }));
|
||||
|
||||
vi.mock("node:dns/promises", () => ({ default: { lookup: mocks.lookup }, lookup: mocks.lookup }));
|
||||
|
||||
vi.mock("@homarr/request-handler/timetable", () => ({
|
||||
timetableGetTimetableRequestHandler: {
|
||||
handler: (input: unknown) => ({
|
||||
getDataAsync: async () => {
|
||||
mocks.getTimetable(input);
|
||||
return { data: { stationId: "station", timestamp: new Date(0), entries: [] } };
|
||||
},
|
||||
}),
|
||||
},
|
||||
timetableSearchStationsRequestHandler: {
|
||||
handler: (input: unknown) => ({
|
||||
getDataAsync: async () => {
|
||||
mocks.searchStations(input);
|
||||
return { data: [] };
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.lookup.mockResolvedValue([{ address: "8.8.8.8", family: 4 }]);
|
||||
});
|
||||
|
||||
const createSession = (userId: string): Session => ({
|
||||
user: { id: userId, permissions: [], colorScheme: "light" },
|
||||
expires: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const createCaller = (db: Database, session: Session | null) =>
|
||||
timetableRouter.createCaller({ db, deviceType: undefined, session });
|
||||
|
||||
const createTimetableItemAsync = async (
|
||||
db: Database,
|
||||
{ baseUrl, isPublic = true }: { baseUrl: string; isPublic?: boolean },
|
||||
) => {
|
||||
const ownerId = createId();
|
||||
const boardId = createId();
|
||||
const itemId = createId();
|
||||
await db.insert(users).values({ id: ownerId });
|
||||
await db.insert(boards).values({ id: boardId, name: `board-${boardId}`, creatorId: ownerId, isPublic });
|
||||
await db.insert(items).values({
|
||||
id: itemId,
|
||||
boardId,
|
||||
kind: "timetable",
|
||||
options: stringify({ baseUrl }),
|
||||
});
|
||||
return { boardId, itemId, ownerId };
|
||||
};
|
||||
|
||||
describe("timetableRouter source authorization", () => {
|
||||
it("allows itemless setup only for the fixed default provider", async () => {
|
||||
const caller = createCaller(createDb(), null);
|
||||
|
||||
await expect(caller.searchStations({ baseUrl: "https://search.ch", query: "Bern" })).resolves.toEqual([]);
|
||||
await expect(caller.searchStations({ baseUrl: "http://127.0.0.1:8080", query: "Bern" })).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports invalid timetable URLs as bad requests", async () => {
|
||||
const caller = createCaller(createDb(), null);
|
||||
|
||||
await expect(
|
||||
caller.searchStations({ baseUrl: "https://search.ch?redirect=/admin", query: "Bern" }),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
it("rejects a persisted private endpoint at runtime even through a viewable timetable item", async () => {
|
||||
const db = createDb();
|
||||
const baseUrl = "http://timetable.internal:8080";
|
||||
const { itemId } = await createTimetableItemAsync(db, { baseUrl });
|
||||
const caller = createCaller(db, null);
|
||||
|
||||
await expect(caller.getTimetable({ baseUrl, itemId, stationId: "station", limit: 10 })).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
expect(mocks.getTimetable).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
caller.getTimetable({ baseUrl: "http://127.0.0.1:8080", itemId, stationId: "station", limit: 10 }),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
|
||||
it("rejects a persisted endpoint when its board is not viewable", async () => {
|
||||
const db = createDb();
|
||||
const baseUrl = "http://timetable.internal:8080";
|
||||
const { itemId } = await createTimetableItemAsync(db, { baseUrl, isPublic: false });
|
||||
|
||||
await expect(
|
||||
createCaller(db, null).getTimetable({ baseUrl, itemId, stationId: "station", limit: 10 }),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("allows board modifiers to search an unsaved public endpoint", async () => {
|
||||
const db = createDb();
|
||||
const baseUrl = "https://timetable.example.com";
|
||||
const { boardId, ownerId } = await createTimetableItemAsync(db, { baseUrl: "https://search.ch", isPublic: false });
|
||||
const caller = createCaller(db, createSession(ownerId));
|
||||
|
||||
await caller.searchStations({ baseUrl, boardId, query: "Bern" });
|
||||
expect(mocks.lookup).toHaveBeenCalledWith("timetable.example.com", { all: true, verbatim: true });
|
||||
expect(mocks.searchStations).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ baseUrl, pinnedAddresses: [{ address: "8.8.8.8", family: 4 }] }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"http://127.0.0.1:8080",
|
||||
"http://timetable.internal:8080",
|
||||
"http://[::1]:8080",
|
||||
"http://[::ffff:127.0.0.1]:8080",
|
||||
"https://[fd00::1]:8443",
|
||||
"https://192.88.99.1",
|
||||
"http://[64:ff9b::7f00:1]:8080",
|
||||
"http://[2002:7f00:1::]:8080",
|
||||
])("blocks an unsaved private endpoint %s even for board modifiers", async (baseUrl) => {
|
||||
const db = createDb();
|
||||
const { boardId, ownerId } = await createTimetableItemAsync(db, {
|
||||
baseUrl: "https://search.ch",
|
||||
isPublic: false,
|
||||
});
|
||||
const caller = createCaller(db, createSession(ownerId));
|
||||
|
||||
await expect(caller.searchStations({ baseUrl, boardId, query: "Bern" })).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
expect(mocks.searchStations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks an RFC 2765 IPv4-translated loopback address", async () => {
|
||||
const db = createDb();
|
||||
const { boardId, ownerId } = await createTimetableItemAsync(db, {
|
||||
baseUrl: "https://search.ch",
|
||||
isPublic: false,
|
||||
});
|
||||
const caller = createCaller(db, createSession(ownerId));
|
||||
|
||||
await expect(
|
||||
caller.searchStations({ baseUrl: "http://[::ffff:0:7f00:1]:8080", boardId, query: "Bern" }),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(mocks.searchStations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks an unsaved public hostname that resolves to a private address", async () => {
|
||||
mocks.lookup.mockResolvedValueOnce([{ address: "10.0.0.8", family: 4 }]);
|
||||
const db = createDb();
|
||||
const { boardId, ownerId } = await createTimetableItemAsync(db, {
|
||||
baseUrl: "https://search.ch",
|
||||
isPublic: false,
|
||||
});
|
||||
const caller = createCaller(db, createSession(ownerId));
|
||||
|
||||
await expect(
|
||||
caller.searchStations({ baseUrl: "https://timetable.example.com", boardId, query: "Bern" }),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(mocks.searchStations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects the exact saved private endpoint at runtime", async () => {
|
||||
const db = createDb();
|
||||
const baseUrl = "http://timetable.internal:8080";
|
||||
const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, { baseUrl, isPublic: false });
|
||||
|
||||
await expect(
|
||||
createCaller(db, createSession(ownerId)).searchStations({ baseUrl, boardId, itemId, query: "Bern" }),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
|
||||
expect(mocks.lookup).not.toHaveBeenCalled();
|
||||
expect(mocks.searchStations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-resolves and pins a saved public endpoint for searches and timetable requests", async () => {
|
||||
const db = createDb();
|
||||
const baseUrl = "https://timetable.example.com";
|
||||
const pinnedAddresses = [
|
||||
{ address: "93.184.216.34", family: 4 },
|
||||
{ address: "93.184.216.35", family: 4 },
|
||||
{ address: "2606:2800:220:1:248:1893:25c8:1946", family: 6 },
|
||||
];
|
||||
mocks.lookup.mockResolvedValue([pinnedAddresses[2], pinnedAddresses[1], pinnedAddresses[0]]);
|
||||
const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, { baseUrl, isPublic: false });
|
||||
const caller = createCaller(db, createSession(ownerId));
|
||||
|
||||
await caller.searchStations({ baseUrl, boardId, itemId, query: "Bern" });
|
||||
await caller.getTimetable({ baseUrl, itemId, stationId: "station", limit: 10 });
|
||||
|
||||
expect(mocks.lookup).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.searchStations).toHaveBeenLastCalledWith(expect.objectContaining({ baseUrl, pinnedAddresses }));
|
||||
expect(mocks.getTimetable).toHaveBeenLastCalledWith(expect.objectContaining({ baseUrl, pinnedAddresses }));
|
||||
});
|
||||
|
||||
it("rejects a saved public hostname when it resolves to a private address at request time", async () => {
|
||||
const db = createDb();
|
||||
const baseUrl = "https://timetable.example.com";
|
||||
mocks.lookup.mockResolvedValue([{ address: "10.0.0.8", family: 4 }]);
|
||||
const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, { baseUrl, isPublic: false });
|
||||
const caller = createCaller(db, createSession(ownerId));
|
||||
|
||||
await expect(caller.searchStations({ baseUrl, boardId, itemId, query: "Bern" })).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
await expect(caller.getTimetable({ baseUrl, itemId, stationId: "station", limit: 10 })).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
expect(mocks.searchStations).not.toHaveBeenCalled();
|
||||
expect(mocks.getTimetable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks changing a saved timetable to a private endpoint through item options", async () => {
|
||||
const db = createDb();
|
||||
const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, {
|
||||
baseUrl: "https://search.ch",
|
||||
isPublic: false,
|
||||
});
|
||||
const caller = optionsRouter.createCaller({ db, deviceType: undefined, session: createSession(ownerId) });
|
||||
|
||||
await expect(
|
||||
caller.saveItemOptions({ boardId, itemId, newOptions: { baseUrl: "http://127.0.0.1:8080" } }),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
|
||||
const item = await db.query.items.findFirst({ where: eq(items.id, itemId) });
|
||||
expect(parse<Record<string, unknown>>(item?.options ?? "{}")).toMatchObject({ baseUrl: "https://search.ch" });
|
||||
});
|
||||
|
||||
it("blocks changing a saved timetable to a private endpoint through a board save", async () => {
|
||||
const db = createDb();
|
||||
const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, {
|
||||
baseUrl: "https://search.ch",
|
||||
isPublic: false,
|
||||
});
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: createSession(ownerId) });
|
||||
|
||||
await expect(
|
||||
caller.saveBoard({
|
||||
id: boardId,
|
||||
sections: [],
|
||||
items: [
|
||||
{
|
||||
id: itemId,
|
||||
kind: "timetable",
|
||||
options: { baseUrl: "http://127.0.0.1:8080" },
|
||||
integrationIds: [],
|
||||
layouts: [],
|
||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
it("blocks adding a timetable with a private endpoint", async () => {
|
||||
const db = createDb();
|
||||
const { boardId, ownerId } = await createTimetableItemAsync(db, {
|
||||
baseUrl: "https://search.ch",
|
||||
isPublic: false,
|
||||
});
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: createSession(ownerId) });
|
||||
|
||||
await expect(
|
||||
caller.addItem({
|
||||
boardId,
|
||||
kind: "timetable",
|
||||
options: { baseUrl: "http://127.0.0.1:8080" },
|
||||
integrationIds: [],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
it("rejects an item ID that does not belong to the requested board", async () => {
|
||||
const db = createDb();
|
||||
const first = await createTimetableItemAsync(db, { baseUrl: "http://timetable.internal:8080", isPublic: false });
|
||||
const second = await createTimetableItemAsync(db, { baseUrl: "https://search.ch", isPublic: false });
|
||||
|
||||
await expect(
|
||||
createCaller(db, createSession(second.ownerId)).searchStations({
|
||||
baseUrl: "http://timetable.internal:8080",
|
||||
boardId: second.boardId,
|
||||
itemId: first.itemId,
|
||||
query: "Bern",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
expect(mocks.searchStations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks a changed private endpoint for an existing item", async () => {
|
||||
const db = createDb();
|
||||
const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, {
|
||||
baseUrl: "http://timetable.internal:8080",
|
||||
isPublic: false,
|
||||
});
|
||||
|
||||
await expect(
|
||||
createCaller(db, createSession(ownerId)).searchStations({
|
||||
baseUrl: "http://127.0.0.1:8080",
|
||||
boardId,
|
||||
itemId,
|
||||
query: "Bern",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(mocks.searchStations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a changed public endpoint for an existing item and pins its address", async () => {
|
||||
const db = createDb();
|
||||
const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, {
|
||||
baseUrl: "http://timetable.internal:8080",
|
||||
isPublic: false,
|
||||
});
|
||||
const baseUrl = "https://timetable.example.com";
|
||||
|
||||
await createCaller(db, createSession(ownerId)).searchStations({ baseUrl, boardId, itemId, query: "Bern" });
|
||||
|
||||
expect(mocks.searchStations).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ baseUrl, pinnedAddresses: [{ address: "8.8.8.8", family: 4 }] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports malformed saved widget options as server data corruption", async () => {
|
||||
const db = createDb();
|
||||
const { itemId } = await createTimetableItemAsync(db, { baseUrl: "https://search.ch" });
|
||||
await db.update(items).set({ options: "{" }).where(eq(items.id, itemId));
|
||||
|
||||
await expect(
|
||||
createCaller(db, null).getTimetable({
|
||||
baseUrl: "https://search.ch",
|
||||
itemId,
|
||||
stationId: "station",
|
||||
limit: 10,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "INTERNAL_SERVER_ERROR", message: "Timetable widget configuration is invalid" });
|
||||
});
|
||||
|
||||
it("reports an invalid saved timetable URL as server data corruption", async () => {
|
||||
const db = createDb();
|
||||
const { itemId } = await createTimetableItemAsync(db, {
|
||||
baseUrl: "https://search.ch?redirect=/admin",
|
||||
});
|
||||
|
||||
await expect(
|
||||
createCaller(db, null).getTimetable({
|
||||
baseUrl: "https://search.ch",
|
||||
itemId,
|
||||
stationId: "station",
|
||||
limit: 10,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "INTERNAL_SERVER_ERROR", message: "Timetable widget configuration is invalid" });
|
||||
});
|
||||
|
||||
it("does not treat public board view access as configuration access", async () => {
|
||||
const db = createDb();
|
||||
const baseUrl = "http://timetable.internal:8080";
|
||||
const { boardId, itemId } = await createTimetableItemAsync(db, { baseUrl });
|
||||
|
||||
await expect(createCaller(db, null).searchStations({ baseUrl, boardId, query: "Bern" })).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
await expect(createCaller(db, null).searchStations({ baseUrl, itemId, query: "Bern" })).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user