feat: implement board access control (#349)
* feat: implement board access control * fix: deepsource issues * wip: address pull request feedback * chore: address pull request feedback * fix: format issue * test: improve tests * fix: type and lint issue * chore: address pull request feedback * refactor: rename board procedures
This commit is contained in:
@@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
|
||||
import type { Database, SQL } from "@homarr/db";
|
||||
import { and, createId, eq, inArray } from "@homarr/db";
|
||||
import { and, createId, eq, inArray, or } from "@homarr/db";
|
||||
import {
|
||||
boardPermissions,
|
||||
boards,
|
||||
@@ -20,7 +20,8 @@ import {
|
||||
} from "@homarr/validation";
|
||||
|
||||
import { zodUnionFromArray } from "../../../validation/src/enums";
|
||||
import { createTRPCRouter, publicProcedure } from "../trpc";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
||||
|
||||
const filterAddedItems = <TInput extends { id: string }>(
|
||||
inputArray: TInput[],
|
||||
@@ -47,23 +48,41 @@ const filterUpdatedItems = <TInput extends { id: string }>(
|
||||
);
|
||||
|
||||
export const boardRouter = createTRPCRouter({
|
||||
getAll: publicProcedure.query(async ({ ctx }) => {
|
||||
return await ctx.db.query.boards.findMany({
|
||||
getAllBoards: publicProcedure.query(async ({ ctx }) => {
|
||||
const permissionsOfCurrentUserWhenPresent =
|
||||
await ctx.db.query.boardPermissions.findMany({
|
||||
where: eq(boardPermissions.userId, ctx.session?.user.id ?? ""),
|
||||
});
|
||||
const boardIds = permissionsOfCurrentUserWhenPresent.map(
|
||||
(permission) => permission.boardId,
|
||||
);
|
||||
const dbBoards = await ctx.db.query.boards.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
isPublic: true,
|
||||
},
|
||||
with: {
|
||||
sections: {
|
||||
with: {
|
||||
items: true,
|
||||
creator: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
where: eq(boardPermissions.userId, ctx.session?.user.id ?? ""),
|
||||
},
|
||||
},
|
||||
where: or(
|
||||
eq(boards.isPublic, true),
|
||||
eq(boards.creatorId, ctx.session?.user.id ?? ""),
|
||||
boardIds.length > 0 ? inArray(boards.id, boardIds) : undefined,
|
||||
),
|
||||
});
|
||||
return dbBoards;
|
||||
}),
|
||||
create: publicProcedure
|
||||
createBoard: protectedProcedure
|
||||
.input(validation.board.create)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const boardId = createId();
|
||||
@@ -71,6 +90,7 @@ export const boardRouter = createTRPCRouter({
|
||||
await transaction.insert(boards).values({
|
||||
id: boardId,
|
||||
name: input.name,
|
||||
creatorId: ctx.session.user.id,
|
||||
});
|
||||
await transaction.insert(sections).values({
|
||||
id: createId(),
|
||||
@@ -80,9 +100,15 @@ export const boardRouter = createTRPCRouter({
|
||||
});
|
||||
});
|
||||
}),
|
||||
rename: publicProcedure
|
||||
renameBoard: protectedProcedure
|
||||
.input(validation.board.rename)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(
|
||||
ctx,
|
||||
eq(boards.id, input.id),
|
||||
"full-access",
|
||||
);
|
||||
|
||||
await noBoardWithSimilarName(ctx.db, input.name, [input.id]);
|
||||
|
||||
await ctx.db
|
||||
@@ -90,40 +116,61 @@ export const boardRouter = createTRPCRouter({
|
||||
.set({ name: input.name })
|
||||
.where(eq(boards.id, input.id));
|
||||
}),
|
||||
changeVisibility: publicProcedure
|
||||
changeBoardVisibility: protectedProcedure
|
||||
.input(validation.board.changeVisibility)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(
|
||||
ctx,
|
||||
eq(boards.id, input.id),
|
||||
"full-access",
|
||||
);
|
||||
|
||||
await ctx.db
|
||||
.update(boards)
|
||||
.set({ isPublic: input.visibility === "public" })
|
||||
.where(eq(boards.id, input.id));
|
||||
}),
|
||||
delete: publicProcedure
|
||||
deleteBoard: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(
|
||||
ctx,
|
||||
eq(boards.id, input.id),
|
||||
"full-access",
|
||||
);
|
||||
|
||||
await ctx.db.delete(boards).where(eq(boards.id, input.id));
|
||||
}),
|
||||
default: publicProcedure.query(async ({ ctx }) => {
|
||||
return await getFullBoardWithWhere(ctx.db, eq(boards.name, "default"));
|
||||
getDefaultBoard: publicProcedure.query(async ({ ctx }) => {
|
||||
const boardWhere = eq(boards.name, "default");
|
||||
await throwIfActionForbiddenAsync(ctx, boardWhere, "board-view");
|
||||
|
||||
return await getFullBoardWithWhere(
|
||||
ctx.db,
|
||||
boardWhere,
|
||||
ctx.session?.user.id ?? null,
|
||||
);
|
||||
}),
|
||||
byName: publicProcedure
|
||||
getBoardByName: publicProcedure
|
||||
.input(validation.board.byName)
|
||||
.query(async ({ input, ctx }) => {
|
||||
return await getFullBoardWithWhere(ctx.db, eq(boards.name, input.name));
|
||||
const boardWhere = eq(boards.name, input.name);
|
||||
await throwIfActionForbiddenAsync(ctx, boardWhere, "board-view");
|
||||
|
||||
return await getFullBoardWithWhere(
|
||||
ctx.db,
|
||||
boardWhere,
|
||||
ctx.session?.user.id ?? null,
|
||||
);
|
||||
}),
|
||||
savePartialSettings: publicProcedure
|
||||
savePartialBoardSettings: protectedProcedure
|
||||
.input(validation.board.savePartialSettings)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const board = await ctx.db.query.boards.findFirst({
|
||||
where: eq(boards.id, input.id),
|
||||
});
|
||||
|
||||
if (!board) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Board not found",
|
||||
});
|
||||
}
|
||||
await throwIfActionForbiddenAsync(
|
||||
ctx,
|
||||
eq(boards.id, input.id),
|
||||
"board-change",
|
||||
);
|
||||
|
||||
await ctx.db
|
||||
.update(boards)
|
||||
@@ -153,13 +200,20 @@ export const boardRouter = createTRPCRouter({
|
||||
})
|
||||
.where(eq(boards.id, input.id));
|
||||
}),
|
||||
save: publicProcedure
|
||||
saveBoard: protectedProcedure
|
||||
.input(validation.board.save)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await throwIfActionForbiddenAsync(
|
||||
ctx,
|
||||
eq(boards.id, input.id),
|
||||
"board-change",
|
||||
);
|
||||
|
||||
await ctx.db.transaction(async (transaction) => {
|
||||
const dbBoard = await getFullBoardWithWhere(
|
||||
transaction,
|
||||
eq(boards.id, input.id),
|
||||
ctx.session.user.id,
|
||||
);
|
||||
|
||||
const addedSections = filterAddedItems(
|
||||
@@ -314,9 +368,15 @@ export const boardRouter = createTRPCRouter({
|
||||
});
|
||||
}),
|
||||
|
||||
permissions: publicProcedure
|
||||
getBoardPermissions: protectedProcedure
|
||||
.input(validation.board.permissions)
|
||||
.query(async ({ input, ctx }) => {
|
||||
await throwIfActionForbiddenAsync(
|
||||
ctx,
|
||||
eq(boards.id, input.id),
|
||||
"full-access",
|
||||
);
|
||||
|
||||
const permissions = await ctx.db.query.boardPermissions.findMany({
|
||||
where: eq(boardPermissions.boardId, input.id),
|
||||
with: {
|
||||
@@ -340,9 +400,15 @@ export const boardRouter = createTRPCRouter({
|
||||
return permissionA.user.name.localeCompare(permissionB.user.name);
|
||||
});
|
||||
}),
|
||||
savePermissions: publicProcedure
|
||||
saveBoardPermissions: protectedProcedure
|
||||
.input(validation.board.savePermissions)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await throwIfActionForbiddenAsync(
|
||||
ctx,
|
||||
eq(boards.id, input.id),
|
||||
"full-access",
|
||||
);
|
||||
|
||||
await ctx.db.transaction(async (transaction) => {
|
||||
await transaction
|
||||
.delete(boardPermissions)
|
||||
@@ -387,7 +453,11 @@ const noBoardWithSimilarName = async (
|
||||
}
|
||||
};
|
||||
|
||||
const getFullBoardWithWhere = async (db: Database, where: SQL<unknown>) => {
|
||||
const getFullBoardWithWhere = async (
|
||||
db: Database,
|
||||
where: SQL<unknown>,
|
||||
userId: string | null,
|
||||
) => {
|
||||
const board = await db.query.boards.findFirst({
|
||||
where,
|
||||
with: {
|
||||
@@ -410,6 +480,12 @@ const getFullBoardWithWhere = async (db: Database, where: SQL<unknown>) => {
|
||||
},
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
where: eq(boardPermissions.userId, userId ?? ""),
|
||||
columns: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -437,8 +513,6 @@ const getFullBoardWithWhere = async (db: Database, where: SQL<unknown>) => {
|
||||
};
|
||||
};
|
||||
|
||||
// The following is a bit of a mess, it's providing us typesafe options matching the widget kind.
|
||||
// But I might be able to do this in a better way in the future.
|
||||
const forKind = <T extends WidgetKind>(kind: T) =>
|
||||
z.object({
|
||||
kind: z.literal(kind),
|
||||
|
||||
67
packages/api/src/router/board/board-access.ts
Normal file
67
packages/api/src/router/board/board-access.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||
import type { Database, SQL } from "@homarr/db";
|
||||
import { eq } from "@homarr/db";
|
||||
import { boardPermissions } from "@homarr/db/schema/sqlite";
|
||||
import type { BoardPermission } from "@homarr/definitions";
|
||||
|
||||
/**
|
||||
* Throws NOT_FOUND if user is not allowed to perform action on board
|
||||
* @param ctx trpc router context
|
||||
* @param boardWhere where clause for the board
|
||||
* @param permission permission required to perform action on board
|
||||
*/
|
||||
export const throwIfActionForbiddenAsync = async (
|
||||
ctx: { db: Database; session: Session | null },
|
||||
boardWhere: SQL<unknown>,
|
||||
permission: "full-access" | BoardPermission,
|
||||
) => {
|
||||
const { db, session } = ctx;
|
||||
const board = await db.query.boards.findFirst({
|
||||
where: boardWhere,
|
||||
columns: {
|
||||
id: true,
|
||||
creatorId: true,
|
||||
isPublic: true,
|
||||
},
|
||||
with: {
|
||||
permissions: {
|
||||
where: eq(boardPermissions.userId, session?.user.id ?? ""),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!board) {
|
||||
notAllowed();
|
||||
}
|
||||
|
||||
const { hasViewAccess, hasChangeAccess, hasFullAccess } =
|
||||
constructBoardPermissions(board, session);
|
||||
|
||||
if (hasFullAccess) {
|
||||
return; // As full access is required and user has full access, allow
|
||||
}
|
||||
|
||||
if (["board-change", "board-view"].includes(permission) && hasChangeAccess) {
|
||||
return; // As change access is required and user has change access, allow
|
||||
}
|
||||
|
||||
if (permission === "board-view" && hasViewAccess) {
|
||||
return; // As view access is required and user has view access, allow
|
||||
}
|
||||
|
||||
notAllowed();
|
||||
};
|
||||
|
||||
/**
|
||||
* This method returns NOT_FOUND to prevent snooping on board existence
|
||||
* A function is used to use the method without return statement
|
||||
*/
|
||||
function notAllowed(): never {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Board not found",
|
||||
});
|
||||
}
|
||||
@@ -10,89 +10,168 @@ import {
|
||||
integrations,
|
||||
items,
|
||||
sections,
|
||||
users,
|
||||
} from "@homarr/db/schema/sqlite";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import type { RouterOutputs } from "../..";
|
||||
import { boardRouter } from "../board";
|
||||
import * as boardAccess from "../board/board-access";
|
||||
import { expectToBeDefined } from "./helper";
|
||||
|
||||
const defaultCreatorId = createId();
|
||||
const defaultSession = {
|
||||
user: {
|
||||
id: defaultCreatorId,
|
||||
},
|
||||
expires: new Date().toISOString(),
|
||||
} satisfies Session;
|
||||
|
||||
// Mock the auth module to return an empty session
|
||||
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
|
||||
|
||||
export const expectToBeDefined = <T>(value: T) => {
|
||||
if (value === undefined) {
|
||||
expect(value).toBeDefined();
|
||||
}
|
||||
if (value === null) {
|
||||
expect(value).not.toBeNull();
|
||||
}
|
||||
return value as Exclude<T, undefined | null>;
|
||||
};
|
||||
|
||||
describe("default should return default board", () => {
|
||||
describe("getDefaultBoard should return default board", () => {
|
||||
it("should return default board", async () => {
|
||||
// Arrange
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: null });
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
const fullBoardProps = await createFullBoardAsync(db, "default");
|
||||
|
||||
const result = await caller.default();
|
||||
// Act
|
||||
const result = await caller.getDefaultBoard();
|
||||
|
||||
// Assert
|
||||
expectInputToBeFullBoardWithName(result, {
|
||||
name: "default",
|
||||
...fullBoardProps,
|
||||
});
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"board-view",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("byName should return board by name", () => {
|
||||
describe("getBoardByName should return board by name", () => {
|
||||
it.each([["default"], ["something"]])(
|
||||
"should return board by name %s when present",
|
||||
async (name) => {
|
||||
// Arrange
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: null });
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
const fullBoardProps = await createFullBoardAsync(db, name);
|
||||
|
||||
const result = await caller.byName({ name });
|
||||
// Act
|
||||
const result = await caller.getBoardByName({ name });
|
||||
|
||||
// Assert
|
||||
expectInputToBeFullBoardWithName(result, {
|
||||
name,
|
||||
...fullBoardProps,
|
||||
});
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"board-view",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("should throw error when not present");
|
||||
it("should throw error when not present", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
await createFullBoardAsync(db, "default");
|
||||
|
||||
// Act
|
||||
const act = async () =>
|
||||
await caller.getBoardByName({ name: "nonExistentBoard" });
|
||||
|
||||
// Assert
|
||||
await expect(act()).rejects.toThrowError("Board not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("savePartialSettings should save general settings", () => {
|
||||
describe("savePartialBoardSettings should save general settings", () => {
|
||||
it("should save general settings", async () => {
|
||||
// Arrange
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: null });
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
const newPageTitle = "newPageTitle";
|
||||
const newMetaTitle = "newMetaTitle";
|
||||
const newLogoImageUrl = "http://logo.image/url.png";
|
||||
const newFaviconImageUrl = "http://favicon.image/url.png";
|
||||
const newBackgroundImageAttachment = "scroll";
|
||||
const newBackgroundImageSize = "cover";
|
||||
const newBackgroundImageRepeat = "repeat";
|
||||
const newBackgroundImageUrl = "http://background.image/url.png";
|
||||
const newColumnCount = 2;
|
||||
const newCustomCss = "body { background-color: blue; }";
|
||||
const newOpacity = 0.8;
|
||||
const newPrimaryColor = "#0000ff";
|
||||
const newSecondaryColor = "#ff00ff";
|
||||
|
||||
const { boardId } = await createFullBoardAsync(db, "default");
|
||||
|
||||
await caller.savePartialSettings({
|
||||
// Act
|
||||
await caller.savePartialBoardSettings({
|
||||
pageTitle: newPageTitle,
|
||||
metaTitle: newMetaTitle,
|
||||
logoImageUrl: newLogoImageUrl,
|
||||
faviconImageUrl: newFaviconImageUrl,
|
||||
backgroundImageAttachment: newBackgroundImageAttachment,
|
||||
backgroundImageRepeat: newBackgroundImageRepeat,
|
||||
backgroundImageSize: newBackgroundImageSize,
|
||||
backgroundImageUrl: newBackgroundImageUrl,
|
||||
columnCount: newColumnCount,
|
||||
customCss: newCustomCss,
|
||||
opacity: newOpacity,
|
||||
primaryColor: newPrimaryColor,
|
||||
secondaryColor: newSecondaryColor,
|
||||
id: boardId,
|
||||
});
|
||||
|
||||
// Assert
|
||||
const dbBoard = await db.query.boards.findFirst({
|
||||
where: eq(boards.id, boardId),
|
||||
});
|
||||
expect(dbBoard).toBeDefined();
|
||||
expect(dbBoard?.pageTitle).toBe(newPageTitle);
|
||||
expect(dbBoard?.metaTitle).toBe(newMetaTitle);
|
||||
expect(dbBoard?.logoImageUrl).toBe(newLogoImageUrl);
|
||||
expect(dbBoard?.faviconImageUrl).toBe(newFaviconImageUrl);
|
||||
expect(dbBoard?.backgroundImageAttachment).toBe(
|
||||
newBackgroundImageAttachment,
|
||||
);
|
||||
expect(dbBoard?.backgroundImageRepeat).toBe(newBackgroundImageRepeat);
|
||||
expect(dbBoard?.backgroundImageSize).toBe(newBackgroundImageSize);
|
||||
expect(dbBoard?.backgroundImageUrl).toBe(newBackgroundImageUrl);
|
||||
expect(dbBoard?.columnCount).toBe(newColumnCount);
|
||||
expect(dbBoard?.customCss).toBe(newCustomCss);
|
||||
expect(dbBoard?.opacity).toBe(newOpacity);
|
||||
expect(dbBoard?.primaryColor).toBe(newPrimaryColor);
|
||||
expect(dbBoard?.secondaryColor).toBe(newSecondaryColor);
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"board-change",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error when board not found", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: null });
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
const act = async () =>
|
||||
await caller.savePartialSettings({
|
||||
await caller.savePartialBoardSettings({
|
||||
pageTitle: "newPageTitle",
|
||||
metaTitle: "newMetaTitle",
|
||||
logoImageUrl: "http://logo.image/url.png",
|
||||
@@ -104,14 +183,15 @@ describe("savePartialSettings should save general settings", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("save should save full board", () => {
|
||||
describe("saveBoard should save full board", () => {
|
||||
it("should remove section when not present in input", async () => {
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: null });
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
|
||||
|
||||
await caller.save({
|
||||
await caller.saveBoard({
|
||||
id: boardId,
|
||||
sections: [
|
||||
{
|
||||
@@ -138,17 +218,23 @@ describe("save should save full board", () => {
|
||||
expect(definedBoard.sections.length).toBe(1);
|
||||
expect(definedBoard.sections[0]?.id).not.toBe(sectionId);
|
||||
expect(section).toBeUndefined();
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"board-change",
|
||||
);
|
||||
});
|
||||
it("should remove item when not present in input", async () => {
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: null });
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
const { boardId, itemId, sectionId } = await createFullBoardAsync(
|
||||
db,
|
||||
"default",
|
||||
);
|
||||
|
||||
await caller.save({
|
||||
await caller.saveBoard({
|
||||
id: boardId,
|
||||
sections: [
|
||||
{
|
||||
@@ -192,10 +278,16 @@ describe("save should save full board", () => {
|
||||
expect(firstSection.items.length).toBe(1);
|
||||
expect(firstSection.items[0]?.id).not.toBe(itemId);
|
||||
expect(item).toBeUndefined();
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"board-change",
|
||||
);
|
||||
});
|
||||
it("should remove integration reference when not present in input", async () => {
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: null });
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
const anotherIntegration = {
|
||||
id: createId(),
|
||||
kind: "adGuardHome",
|
||||
@@ -207,7 +299,7 @@ describe("save should save full board", () => {
|
||||
await createFullBoardAsync(db, "default");
|
||||
await db.insert(integrations).values(anotherIntegration);
|
||||
|
||||
await caller.save({
|
||||
await caller.saveBoard({
|
||||
id: boardId,
|
||||
sections: [
|
||||
{
|
||||
@@ -257,18 +349,24 @@ describe("save should save full board", () => {
|
||||
expect(firstItem.integrations.length).toBe(1);
|
||||
expect(firstItem.integrations[0]?.integrationId).not.toBe(integrationId);
|
||||
expect(integration).toBeUndefined();
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"board-change",
|
||||
);
|
||||
});
|
||||
it.each([
|
||||
[{ kind: "empty" as const }],
|
||||
[{ kind: "category" as const, 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, session: null });
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
|
||||
|
||||
const newSectionId = createId();
|
||||
await caller.save({
|
||||
await caller.saveBoard({
|
||||
id: boardId,
|
||||
sections: [
|
||||
{
|
||||
@@ -310,15 +408,21 @@ describe("save should save full board", () => {
|
||||
expect(addedSection.name).toBe(partialSection.name);
|
||||
}
|
||||
expect(section).toBeDefined();
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"board-change",
|
||||
);
|
||||
});
|
||||
it("should add item when present in input", async () => {
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: null });
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
|
||||
|
||||
const newItemId = createId();
|
||||
await caller.save({
|
||||
await caller.saveBoard({
|
||||
id: boardId,
|
||||
sections: [
|
||||
{
|
||||
@@ -374,10 +478,16 @@ describe("save should save full board", () => {
|
||||
expect(addedItem.xOffset).toBe(3);
|
||||
expect(addedItem.yOffset).toBe(2);
|
||||
expect(item).toBeDefined();
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"board-change",
|
||||
);
|
||||
});
|
||||
it("should add integration reference when present in input", async () => {
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: null });
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
const integration = {
|
||||
id: createId(),
|
||||
kind: "plex",
|
||||
@@ -391,7 +501,7 @@ describe("save should save full board", () => {
|
||||
);
|
||||
await db.insert(integrations).values(integration);
|
||||
|
||||
await caller.save({
|
||||
await caller.saveBoard({
|
||||
id: boardId,
|
||||
sections: [
|
||||
{
|
||||
@@ -443,10 +553,15 @@ describe("save should save full board", () => {
|
||||
expect(firstItem.integrations.length).toBe(1);
|
||||
expect(firstItem.integrations[0]?.integrationId).toBe(integration.id);
|
||||
expect(integrationItem).toBeDefined();
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"board-change",
|
||||
);
|
||||
});
|
||||
it("should update section when present in input", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: null });
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
|
||||
const newSectionId = createId();
|
||||
@@ -458,7 +573,7 @@ describe("save should save full board", () => {
|
||||
boardId,
|
||||
});
|
||||
|
||||
await caller.save({
|
||||
await caller.saveBoard({
|
||||
id: boardId,
|
||||
sections: [
|
||||
{
|
||||
@@ -503,15 +618,16 @@ describe("save should save full board", () => {
|
||||
expect(secondSection.name).toBe("After");
|
||||
});
|
||||
it("should update item when present in input", async () => {
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: null });
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
const { boardId, itemId, sectionId } = await createFullBoardAsync(
|
||||
db,
|
||||
"default",
|
||||
);
|
||||
|
||||
await caller.save({
|
||||
await caller.saveBoard({
|
||||
id: boardId,
|
||||
sections: [
|
||||
{
|
||||
@@ -562,13 +678,18 @@ describe("save should save full board", () => {
|
||||
expect(firstItem.width).toBe(2);
|
||||
expect(firstItem.xOffset).toBe(7);
|
||||
expect(firstItem.yOffset).toBe(5);
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"board-change",
|
||||
);
|
||||
});
|
||||
it("should fail when board not found", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: null });
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
const act = async () =>
|
||||
await caller.save({
|
||||
await caller.saveBoard({
|
||||
id: "nonExistentBoardId",
|
||||
sections: [],
|
||||
});
|
||||
@@ -578,7 +699,7 @@ describe("save should save full board", () => {
|
||||
});
|
||||
|
||||
const expectInputToBeFullBoardWithName = (
|
||||
input: RouterOutputs["board"]["default"],
|
||||
input: RouterOutputs["board"]["getDefaultBoard"],
|
||||
props: { name: string } & Awaited<ReturnType<typeof createFullBoardAsync>>,
|
||||
) => {
|
||||
expect(input.id).toBe(props.boardId);
|
||||
@@ -600,10 +721,15 @@ const expectInputToBeFullBoardWithName = (
|
||||
};
|
||||
|
||||
const createFullBoardAsync = async (db: Database, name: string) => {
|
||||
await db.insert(users).values({
|
||||
id: defaultCreatorId,
|
||||
});
|
||||
|
||||
const boardId = createId();
|
||||
await db.insert(boards).values({
|
||||
id: boardId,
|
||||
name,
|
||||
creatorId: defaultCreatorId,
|
||||
});
|
||||
|
||||
const sectionId = createId();
|
||||
|
||||
188
packages/api/src/router/test/board/board-access.spec.ts
Normal file
188
packages/api/src/router/test/board/board-access.spec.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import * as authShared from "@homarr/auth/shared";
|
||||
import { createId, eq } from "@homarr/db";
|
||||
import { boards, users } from "@homarr/db/schema/sqlite";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { throwIfActionForbiddenAsync } from "../../board/board-access";
|
||||
|
||||
const defaultCreatorId = createId();
|
||||
|
||||
const expectActToBe = async (act: () => Promise<void>, success: boolean) => {
|
||||
if (!success) {
|
||||
await expect(act()).rejects.toThrow("Board not found");
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(act()).resolves.toBeUndefined();
|
||||
};
|
||||
|
||||
// TODO: most of this test can be used for constructBoardPermissions
|
||||
// TODO: the tests for the board-access can be reduced to about 4 tests (as the unit has shrunk)
|
||||
|
||||
describe("throwIfActionForbiddenAsync should check access to board and return boolean", () => {
|
||||
test.each([
|
||||
["full-access" as const, true],
|
||||
["board-change" as const, true],
|
||||
["board-view" as const, true],
|
||||
])(
|
||||
"with permission %s should return %s when hasFullAccess is true",
|
||||
async (permission, expectedResult) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const spy = vi.spyOn(authShared, "constructBoardPermissions");
|
||||
spy.mockReturnValue({
|
||||
hasFullAccess: true,
|
||||
hasChangeAccess: false,
|
||||
hasViewAccess: false,
|
||||
});
|
||||
|
||||
await db.insert(users).values({ id: defaultCreatorId });
|
||||
const boardId = createId();
|
||||
await db.insert(boards).values({
|
||||
id: boardId,
|
||||
name: "test",
|
||||
creatorId: defaultCreatorId,
|
||||
});
|
||||
|
||||
// Act
|
||||
const act = () =>
|
||||
throwIfActionForbiddenAsync(
|
||||
{ db, session: null },
|
||||
eq(boards.id, boardId),
|
||||
permission,
|
||||
);
|
||||
|
||||
// Assert
|
||||
await expectActToBe(act, expectedResult);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
["full-access" as const, false],
|
||||
["board-change" as const, true],
|
||||
["board-view" as const, true],
|
||||
])(
|
||||
"with permission %s should return %s when hasChangeAccess is true",
|
||||
async (permission, expectedResult) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const spy = vi.spyOn(authShared, "constructBoardPermissions");
|
||||
spy.mockReturnValue({
|
||||
hasFullAccess: false,
|
||||
hasChangeAccess: true,
|
||||
hasViewAccess: false,
|
||||
});
|
||||
|
||||
await db.insert(users).values({ id: defaultCreatorId });
|
||||
const boardId = createId();
|
||||
await db.insert(boards).values({
|
||||
id: boardId,
|
||||
name: "test",
|
||||
creatorId: defaultCreatorId,
|
||||
});
|
||||
|
||||
// Act
|
||||
const act = () =>
|
||||
throwIfActionForbiddenAsync(
|
||||
{ db, session: null },
|
||||
eq(boards.id, boardId),
|
||||
permission,
|
||||
);
|
||||
|
||||
// Assert
|
||||
await expectActToBe(act, expectedResult);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
["full-access" as const, false],
|
||||
["board-change" as const, false],
|
||||
["board-view" as const, true],
|
||||
])(
|
||||
"with permission %s should return %s when hasViewAccess is true",
|
||||
async (permission, expectedResult) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const spy = vi.spyOn(authShared, "constructBoardPermissions");
|
||||
spy.mockReturnValue({
|
||||
hasFullAccess: false,
|
||||
hasChangeAccess: false,
|
||||
hasViewAccess: true,
|
||||
});
|
||||
|
||||
await db.insert(users).values({ id: defaultCreatorId });
|
||||
const boardId = createId();
|
||||
await db.insert(boards).values({
|
||||
id: boardId,
|
||||
name: "test",
|
||||
creatorId: defaultCreatorId,
|
||||
});
|
||||
|
||||
// Act
|
||||
const act = () =>
|
||||
throwIfActionForbiddenAsync(
|
||||
{ db, session: null },
|
||||
eq(boards.id, boardId),
|
||||
permission,
|
||||
);
|
||||
|
||||
// Assert
|
||||
await expectActToBe(act, expectedResult);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
["full-access" as const, false],
|
||||
["board-change" as const, false],
|
||||
["board-view" as const, false],
|
||||
])(
|
||||
"with permission %s should return %s when hasViewAccess is false",
|
||||
async (permission, expectedResult) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const spy = vi.spyOn(authShared, "constructBoardPermissions");
|
||||
spy.mockReturnValue({
|
||||
hasFullAccess: false,
|
||||
hasChangeAccess: false,
|
||||
hasViewAccess: false,
|
||||
});
|
||||
|
||||
await db.insert(users).values({ id: defaultCreatorId });
|
||||
const boardId = createId();
|
||||
await db.insert(boards).values({
|
||||
id: boardId,
|
||||
name: "test",
|
||||
creatorId: defaultCreatorId,
|
||||
});
|
||||
|
||||
// Act
|
||||
const act = () =>
|
||||
throwIfActionForbiddenAsync(
|
||||
{ db, session: null },
|
||||
eq(boards.id, boardId),
|
||||
permission,
|
||||
);
|
||||
|
||||
// Assert
|
||||
await expectActToBe(act, expectedResult);
|
||||
},
|
||||
);
|
||||
|
||||
test("should throw when board is not found", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
|
||||
// Act
|
||||
const act = () =>
|
||||
throwIfActionForbiddenAsync(
|
||||
{ db, session: null },
|
||||
eq(boards.id, createId()),
|
||||
"full-access",
|
||||
);
|
||||
|
||||
// Assert
|
||||
await expect(act()).rejects.toThrow("Board not found");
|
||||
});
|
||||
});
|
||||
11
packages/api/src/router/test/helper.ts
Normal file
11
packages/api/src/router/test/helper.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { expect } from "vitest";
|
||||
|
||||
export const expectToBeDefined = <T>(value: T) => {
|
||||
if (value === undefined) {
|
||||
expect(value).toBeDefined();
|
||||
}
|
||||
if (value === null) {
|
||||
expect(value).not.toBeNull();
|
||||
}
|
||||
return value as Exclude<T, undefined | null>;
|
||||
};
|
||||
@@ -7,7 +7,7 @@ import { createDb } from "@homarr/db/test";
|
||||
|
||||
import type { RouterInputs } from "../..";
|
||||
import { encryptSecret, integrationRouter } from "../integration";
|
||||
import { expectToBeDefined } from "./board.spec";
|
||||
import { expectToBeDefined } from "./helper";
|
||||
|
||||
// Mock the auth module to return an empty session
|
||||
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
|
||||
|
||||
Reference in New Issue
Block a user