feat: board access group permissions (#422)
* fix: cache is not exportet from react * fix: format issue * wip: add usage of group permissions * feat: show inherited groups and add manage group * refactor: improve board access management * chore: address pull request feedback * fix: type issues * fix: migrations * test: add unit tests for board permissions, permissions and board router * test: add unit tests for board router and get current user permissions method * fix: format issues * fix: deepsource issue
This commit is contained in:
@@ -1,11 +1,16 @@
|
||||
import SuperJSON from "superjson";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, test, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { createId, eq } from "@homarr/db";
|
||||
import {
|
||||
boardGroupPermissions,
|
||||
boards,
|
||||
boardUserPermissions,
|
||||
groupMembers,
|
||||
groupPermissions,
|
||||
groups,
|
||||
integrationItems,
|
||||
integrations,
|
||||
items,
|
||||
@@ -13,6 +18,7 @@ import {
|
||||
users,
|
||||
} from "@homarr/db/schema/sqlite";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import type { BoardPermission, GroupPermissionKey } from "@homarr/definitions";
|
||||
|
||||
import type { RouterOutputs } from "../..";
|
||||
import { boardRouter } from "../board";
|
||||
@@ -23,6 +29,7 @@ const defaultCreatorId = createId();
|
||||
const defaultSession = {
|
||||
user: {
|
||||
id: defaultCreatorId,
|
||||
permissions: [],
|
||||
},
|
||||
expires: new Date().toISOString(),
|
||||
} satisfies Session;
|
||||
@@ -30,6 +37,462 @@ const defaultSession = {
|
||||
// Mock the auth module to return an empty session
|
||||
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
|
||||
|
||||
const createRandomUser = async (db: Database) => {
|
||||
const userId = createId();
|
||||
await db.insert(users).values({
|
||||
id: userId,
|
||||
});
|
||||
return userId;
|
||||
};
|
||||
|
||||
describe("getAllBoards should return all boards accessable to the current user", () => {
|
||||
test("without session it should return only public boards", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: null });
|
||||
|
||||
const user1 = await createRandomUser(db);
|
||||
const user2 = await createRandomUser(db);
|
||||
|
||||
await db.insert(boards).values([
|
||||
{
|
||||
id: createId(),
|
||||
name: "public",
|
||||
creatorId: user1,
|
||||
isPublic: true,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
name: "private",
|
||||
creatorId: user2,
|
||||
isPublic: false,
|
||||
},
|
||||
]);
|
||||
|
||||
// Act
|
||||
const result = await caller.getAllBoards();
|
||||
|
||||
// Assert
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0]?.name).toBe("public");
|
||||
});
|
||||
|
||||
test("with session containing board-view-all permission it should return all boards", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({
|
||||
db,
|
||||
session: {
|
||||
user: {
|
||||
id: defaultCreatorId,
|
||||
permissions: ["board-view-all"],
|
||||
},
|
||||
expires: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
const user1 = await createRandomUser(db);
|
||||
const user2 = await createRandomUser(db);
|
||||
|
||||
await db.insert(boards).values([
|
||||
{
|
||||
id: createId(),
|
||||
name: "public",
|
||||
creatorId: user1,
|
||||
isPublic: true,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
name: "private",
|
||||
creatorId: user2,
|
||||
isPublic: false,
|
||||
},
|
||||
]);
|
||||
|
||||
// Act
|
||||
const result = await caller.getAllBoards();
|
||||
|
||||
// Assert
|
||||
expect(result.length).toBe(2);
|
||||
expect(result.map((board) => board.name)).toEqual(["public", "private"]);
|
||||
});
|
||||
|
||||
test("with session user beeing creator it should return all private boards of them", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
const user1 = await createRandomUser(db);
|
||||
const user2 = await createRandomUser(db);
|
||||
await db.insert(users).values({
|
||||
id: defaultCreatorId,
|
||||
});
|
||||
|
||||
await db.insert(boards).values([
|
||||
{
|
||||
id: createId(),
|
||||
name: "public",
|
||||
creatorId: user1,
|
||||
isPublic: true,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
name: "private",
|
||||
creatorId: user2,
|
||||
isPublic: false,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
name: "private2",
|
||||
creatorId: defaultCreatorId,
|
||||
isPublic: false,
|
||||
},
|
||||
]);
|
||||
|
||||
// Act
|
||||
const result = await caller.getAllBoards();
|
||||
|
||||
// Assert
|
||||
expect(result.length).toBe(2);
|
||||
expect(result.map(({ name }) => name)).toStrictEqual([
|
||||
"public",
|
||||
"private2",
|
||||
]);
|
||||
});
|
||||
|
||||
test.each([["board-view"], ["board-change"]] satisfies [BoardPermission][])(
|
||||
"with %s group board permission it should show board",
|
||||
async (permission) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({
|
||||
db,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
const user1 = await createRandomUser(db);
|
||||
const user2 = await createRandomUser(db);
|
||||
await db.insert(users).values({
|
||||
id: defaultCreatorId,
|
||||
});
|
||||
const boardId = createId();
|
||||
|
||||
await db.insert(boards).values([
|
||||
{
|
||||
id: createId(),
|
||||
name: "public",
|
||||
creatorId: user1,
|
||||
isPublic: true,
|
||||
},
|
||||
{
|
||||
id: boardId,
|
||||
name: "private1",
|
||||
creatorId: user2,
|
||||
isPublic: false,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
name: "private2",
|
||||
creatorId: user2,
|
||||
isPublic: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "group1",
|
||||
});
|
||||
|
||||
await db.insert(groupMembers).values({
|
||||
userId: defaultSession.user.id,
|
||||
groupId,
|
||||
});
|
||||
|
||||
await db.insert(boardGroupPermissions).values({
|
||||
groupId,
|
||||
permission,
|
||||
boardId,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await caller.getAllBoards();
|
||||
|
||||
// Assert
|
||||
expect(result.length).toBe(2);
|
||||
expect(result.map(({ name }) => name)).toStrictEqual([
|
||||
"public",
|
||||
"private1",
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([["board-view"], ["board-change"]] satisfies [BoardPermission][])(
|
||||
"with %s user board permission it should show board",
|
||||
async (permission) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({
|
||||
db,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
const user1 = await createRandomUser(db);
|
||||
const user2 = await createRandomUser(db);
|
||||
await db.insert(users).values({
|
||||
id: defaultCreatorId,
|
||||
});
|
||||
const boardId = createId();
|
||||
|
||||
await db.insert(boards).values([
|
||||
{
|
||||
id: createId(),
|
||||
name: "public",
|
||||
creatorId: user1,
|
||||
isPublic: true,
|
||||
},
|
||||
{
|
||||
id: boardId,
|
||||
name: "private1",
|
||||
creatorId: user2,
|
||||
isPublic: false,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
name: "private2",
|
||||
creatorId: user2,
|
||||
isPublic: false,
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(boardUserPermissions).values({
|
||||
userId: defaultSession.user.id,
|
||||
permission,
|
||||
boardId,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await caller.getAllBoards();
|
||||
|
||||
// Assert
|
||||
expect(result.length).toBe(2);
|
||||
expect(result.map(({ name }) => name)).toStrictEqual([
|
||||
"public",
|
||||
"private1",
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("createBoard should create a new board", () => {
|
||||
test("should create a new board with permission board-create", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const session = {
|
||||
...defaultSession,
|
||||
user: {
|
||||
...defaultSession.user,
|
||||
permissions: ["board-create"] satisfies GroupPermissionKey[],
|
||||
},
|
||||
};
|
||||
const caller = boardRouter.createCaller({ db, session });
|
||||
|
||||
await db.insert(users).values({
|
||||
id: defaultCreatorId,
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.createBoard({ name: "newBoard" });
|
||||
|
||||
// Assert
|
||||
const dbBoard = await db.query.boards.findFirst();
|
||||
expect(dbBoard).toBeDefined();
|
||||
expect(dbBoard?.name).toBe("newBoard");
|
||||
expect(dbBoard?.creatorId).toBe(defaultCreatorId);
|
||||
|
||||
const dbSection = await db.query.sections.findFirst();
|
||||
expect(dbSection).toBeDefined();
|
||||
expect(dbSection?.boardId).toBe(dbBoard?.id);
|
||||
expect(dbSection?.kind).toBe("empty");
|
||||
});
|
||||
|
||||
test("should throw error when user has no board-create permission", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
// Act
|
||||
const act = async () => await caller.createBoard({ name: "newBoard" });
|
||||
|
||||
// Assert
|
||||
await expect(act()).rejects.toThrowError("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rename board should rename board", () => {
|
||||
test("should rename board", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
|
||||
await db.insert(users).values({
|
||||
id: defaultCreatorId,
|
||||
});
|
||||
const boardId = createId();
|
||||
await db.insert(boards).values({
|
||||
id: boardId,
|
||||
name: "oldName",
|
||||
creatorId: defaultCreatorId,
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.renameBoard({ id: boardId, name: "newName" });
|
||||
|
||||
// Assert
|
||||
const dbBoard = await db.query.boards.findFirst({
|
||||
where: eq(boards.id, boardId),
|
||||
});
|
||||
expect(dbBoard).toBeDefined();
|
||||
expect(dbBoard?.name).toBe("newName");
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"full-access",
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw error when similar board name exists", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
await db.insert(users).values({
|
||||
id: defaultCreatorId,
|
||||
});
|
||||
const boardId = createId();
|
||||
await db.insert(boards).values({
|
||||
id: boardId,
|
||||
name: "oldName",
|
||||
creatorId: defaultCreatorId,
|
||||
});
|
||||
await db.insert(boards).values({
|
||||
id: createId(),
|
||||
name: "newName",
|
||||
creatorId: defaultCreatorId,
|
||||
});
|
||||
|
||||
// Act
|
||||
const act = async () =>
|
||||
await caller.renameBoard({ id: boardId, name: "Newname" });
|
||||
|
||||
// Assert
|
||||
await expect(act()).rejects.toThrowError(
|
||||
"Board with similar name already exists",
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw error when board not found", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
// Act
|
||||
const act = async () =>
|
||||
await caller.renameBoard({ id: "nonExistentBoardId", name: "newName" });
|
||||
|
||||
// Assert
|
||||
await expect(act()).rejects.toThrowError("Board not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("changeBoardVisibility should change board visibility", () => {
|
||||
test.each([["public"], ["private"]] satisfies ["private" | "public"][])(
|
||||
"should change board visibility to %s",
|
||||
async (visibility) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
|
||||
await db.insert(users).values({
|
||||
id: defaultCreatorId,
|
||||
});
|
||||
const boardId = createId();
|
||||
await db.insert(boards).values({
|
||||
id: boardId,
|
||||
name: "board",
|
||||
creatorId: defaultCreatorId,
|
||||
isPublic: visibility === "public",
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.changeBoardVisibility({
|
||||
id: boardId,
|
||||
visibility,
|
||||
});
|
||||
|
||||
// Assert
|
||||
const dbBoard = await db.query.boards.findFirst({
|
||||
where: eq(boards.id, boardId),
|
||||
});
|
||||
expect(dbBoard).toBeDefined();
|
||||
expect(dbBoard?.isPublic).toBe(visibility === "public");
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"full-access",
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("deleteBoard should delete board", () => {
|
||||
test("should delete board", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
|
||||
await db.insert(users).values({
|
||||
id: defaultCreatorId,
|
||||
});
|
||||
const boardId = createId();
|
||||
await db.insert(boards).values({
|
||||
id: boardId,
|
||||
name: "board",
|
||||
creatorId: defaultCreatorId,
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.deleteBoard({ id: boardId });
|
||||
|
||||
// Assert
|
||||
const dbBoard = await db.query.boards.findFirst({
|
||||
where: eq(boards.id, boardId),
|
||||
});
|
||||
expect(dbBoard).toBeUndefined();
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"full-access",
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw error when board not found", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
// Act
|
||||
const act = async () =>
|
||||
await caller.deleteBoard({ id: "nonExistentBoardId" });
|
||||
|
||||
// Assert
|
||||
await expect(act()).rejects.toThrowError("Board not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDefaultBoard should return default board", () => {
|
||||
it("should return default board", async () => {
|
||||
// Arrange
|
||||
@@ -698,6 +1161,183 @@ describe("saveBoard should save full board", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBoardPermissions should return board permissions", () => {
|
||||
test("should return board permissions", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
|
||||
const user1 = await createRandomUser(db);
|
||||
const user2 = await createRandomUser(db);
|
||||
await db.insert(users).values({
|
||||
id: defaultCreatorId,
|
||||
});
|
||||
|
||||
const boardId = createId();
|
||||
await db.insert(boards).values({
|
||||
id: boardId,
|
||||
name: "board",
|
||||
creatorId: defaultCreatorId,
|
||||
});
|
||||
|
||||
await db.insert(boardUserPermissions).values([
|
||||
{
|
||||
userId: user1,
|
||||
permission: "board-view",
|
||||
boardId,
|
||||
},
|
||||
{
|
||||
userId: user2,
|
||||
permission: "board-change",
|
||||
boardId,
|
||||
},
|
||||
]);
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "group1",
|
||||
});
|
||||
|
||||
await db.insert(boardGroupPermissions).values({
|
||||
groupId,
|
||||
permission: "board-view",
|
||||
boardId,
|
||||
});
|
||||
|
||||
await db.insert(groupPermissions).values({
|
||||
groupId,
|
||||
permission: "admin",
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await caller.getBoardPermissions({ id: boardId });
|
||||
|
||||
// Assert
|
||||
expect(result.groupPermissions).toEqual([
|
||||
{ group: { id: groupId, name: "group1" }, permission: "board-view" },
|
||||
]);
|
||||
expect(result.userPermissions).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
user: { id: user1, name: null, image: null },
|
||||
permission: "board-view",
|
||||
},
|
||||
{
|
||||
user: { id: user2, name: null, image: null },
|
||||
permission: "board-change",
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(result.inherited).toEqual([
|
||||
{ group: { id: groupId, name: "group1" }, permission: "admin" },
|
||||
]);
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"full-access",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveUserBoardPermissions should save user board permissions", () => {
|
||||
test.each([["board-view"], ["board-change"]] satisfies [BoardPermission][])(
|
||||
"should save user board permissions",
|
||||
async (permission) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
|
||||
const user1 = await createRandomUser(db);
|
||||
await db.insert(users).values({
|
||||
id: defaultCreatorId,
|
||||
});
|
||||
|
||||
const boardId = createId();
|
||||
await db.insert(boards).values({
|
||||
id: boardId,
|
||||
name: "board",
|
||||
creatorId: defaultCreatorId,
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.saveUserBoardPermissions({
|
||||
id: boardId,
|
||||
permissions: [
|
||||
{
|
||||
itemId: user1,
|
||||
permission,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Assert
|
||||
const dbUserPermission = await db.query.boardUserPermissions.findFirst({
|
||||
where: eq(boardUserPermissions.userId, user1),
|
||||
});
|
||||
expect(dbUserPermission).toBeDefined();
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"full-access",
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("saveGroupBoardPermissions should save group board permissions", () => {
|
||||
test.each([["board-view"], ["board-change"]] satisfies [BoardPermission][])(
|
||||
"should save group board permissions",
|
||||
async (permission) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
|
||||
await db.insert(users).values({
|
||||
id: defaultCreatorId,
|
||||
});
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "group1",
|
||||
});
|
||||
|
||||
const boardId = createId();
|
||||
await db.insert(boards).values({
|
||||
id: boardId,
|
||||
name: "board",
|
||||
creatorId: defaultCreatorId,
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.saveGroupBoardPermissions({
|
||||
id: boardId,
|
||||
permissions: [
|
||||
{
|
||||
itemId: groupId,
|
||||
permission,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Assert
|
||||
const dbGroupPermission = await db.query.boardGroupPermissions.findFirst({
|
||||
where: eq(boardGroupPermissions.groupId, groupId),
|
||||
});
|
||||
expect(dbGroupPermission).toBeDefined();
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"full-access",
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const expectInputToBeFullBoardWithName = (
|
||||
input: RouterOutputs["board"]["getDefaultBoard"],
|
||||
props: { name: string } & Awaited<ReturnType<typeof createFullBoardAsync>>,
|
||||
|
||||
@@ -16,6 +16,7 @@ const defaultOwnerId = createId();
|
||||
const defaultSession = {
|
||||
user: {
|
||||
id: defaultOwnerId,
|
||||
permissions: [],
|
||||
},
|
||||
expires: new Date().toISOString(),
|
||||
} satisfies Session;
|
||||
|
||||
@@ -10,9 +10,10 @@ import { inviteRouter } from "../invite";
|
||||
const defaultSession = {
|
||||
user: {
|
||||
id: createId(),
|
||||
permissions: [],
|
||||
},
|
||||
expires: new Date().toISOString(),
|
||||
};
|
||||
} satisfies Session;
|
||||
|
||||
// Mock the auth module to return an empty session
|
||||
vi.mock("@homarr/auth", async () => {
|
||||
|
||||
Reference in New Issue
Block a user