feat: add integration access settings (#725)

* feat: add integration access settings

* fix: typecheck and test issues

* fix: test timeout

* chore: address pull request feedback

* chore: add throw if action forbidden for integration permissions

* fix: unable to create new migrations because of duplicate prevId in sqlite snapshots

* chore: add sqlite migration for integration permissions

* test: add unit tests for integration access

* test: add permission checks to integration router tests

* test: add unit test for integration permissions

* chore: add mysql migration

* fix: format issues
This commit is contained in:
Meier Lukas authored and GitHub committed 2024-07-08 00:00:37 +02:00
1 parent be711149f7
commit 408cdeb5c3
50 files changed
+4392 -615

No files matched your search

@@ -0,0 +1,155 @@
import { describe, expect, test, vi } from "vitest";
import * as authShared from "@homarr/auth/shared";
import { createId, eq } from "@homarr/db";
import { integrations, users } from "@homarr/db/schema/sqlite";
import { createDb } from "@homarr/db/test";
import { throwIfActionForbiddenAsync } from "../../integration/integration-access";
const defaultCreatorId = createId();
const expectActToBeAsync = async (act: () => Promise<void>, success: boolean) => {
if (!success) {
await expect(act()).rejects.toThrow("Integration not found");
return;
}
await expect(act()).resolves.toBeUndefined();
};
describe("throwIfActionForbiddenAsync should check access to integration and return boolean", () => {
test.each([
["full" as const, true],
["interact" as const, true],
["use" 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, "constructIntegrationPermissions");
spy.mockReturnValue({
hasFullAccess: true,
hasInteractAccess: false,
hasUseAccess: false,
});
const integrationId = createId();
await db.insert(integrations).values({
id: integrationId,
name: "test",
kind: "adGuardHome",
url: "http://localhost:3000",
});
// Act
const act = () =>
throwIfActionForbiddenAsync({ db, session: null }, eq(integrations.id, integrationId), permission);
// Assert
await expectActToBeAsync(act, expectedResult);
});
test.each([
["full" as const, false],
["interact" as const, true],
["use" as const, true],
])("with permission %s should return %s when hasInteractAccess is true", async (permission, expectedResult) => {
// Arrange
const db = createDb();
const spy = vi.spyOn(authShared, "constructIntegrationPermissions");
spy.mockReturnValue({
hasFullAccess: false,
hasInteractAccess: true,
hasUseAccess: false,
});
await db.insert(users).values({ id: defaultCreatorId });
const integrationId = createId();
await db.insert(integrations).values({
id: integrationId,
name: "test",
kind: "adGuardHome",
url: "http://localhost:3000",
});
// Act
const act = () =>
throwIfActionForbiddenAsync({ db, session: null }, eq(integrations.id, integrationId), permission);
// Assert
await expectActToBeAsync(act, expectedResult);
});
test.each([
["full" as const, false],
["interact" as const, false],
["use" as const, true],
])("with permission %s should return %s when hasUseAccess is true", async (permission, expectedResult) => {
// Arrange
const db = createDb();
const spy = vi.spyOn(authShared, "constructIntegrationPermissions");
spy.mockReturnValue({
hasFullAccess: false,
hasInteractAccess: false,
hasUseAccess: true,
});
await db.insert(users).values({ id: defaultCreatorId });
const integrationId = createId();
await db.insert(integrations).values({
id: integrationId,
name: "test",
kind: "adGuardHome",
url: "http://localhost:3000",
});
// Act
const act = () =>
throwIfActionForbiddenAsync({ db, session: null }, eq(integrations.id, integrationId), permission);
// Assert
await expectActToBeAsync(act, expectedResult);
});
test.each([
["full" as const, false],
["interact" as const, false],
["use" as const, false],
])("with permission %s should return %s when hasUseAccess is false", async (permission, expectedResult) => {
// Arrange
const db = createDb();
const spy = vi.spyOn(authShared, "constructIntegrationPermissions");
spy.mockReturnValue({
hasFullAccess: false,
hasInteractAccess: false,
hasUseAccess: false,
});
await db.insert(users).values({ id: defaultCreatorId });
const integrationId = createId();
await db.insert(integrations).values({
id: integrationId,
name: "test",
kind: "adGuardHome",
url: "http://localhost:3000",
});
// Act
const act = () =>
throwIfActionForbiddenAsync({ db, session: null }, eq(integrations.id, integrationId), permission);
// Assert
await expectActToBeAsync(act, expectedResult);
});
test("should throw when integration is not found", async () => {
// Arrange
const db = createDb();
// Act
const act = () => throwIfActionForbiddenAsync({ db, session: null }, eq(integrations.id, createId()), "full");
// Assert
await expect(act()).rejects.toThrow("Integration not found");
});
});
@@ -1,15 +1,26 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { describe, expect, it, vi } from "vitest";
import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { encryptSecret } from "@homarr/common";
import { createId } from "@homarr/db";
import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite";
import { createDb } from "@homarr/db/test";
import type { GroupPermissionKey } from "@homarr/definitions";
import { integrationRouter } from "../../integration/integration-router";
import { expectToBeDefined } from "../helper";
const defaultUserId = createId();
const defaultSessionWithPermissions = (permissions: GroupPermissionKey[] = []) =>
({
user: {
id: defaultUserId,
permissions,
},
expires: new Date().toISOString(),
}) satisfies Session;
// Mock the auth module to return an empty session
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
vi.mock("../../integration/integration-test-connection", () => ({
@@ -17,11 +28,11 @@ vi.mock("../../integration/integration-test-connection", () => ({
}));
describe("all should return all integrations", () => {
it("should return all integrations", async () => {
test("with any session should return all integrations", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
session: null,
session: defaultSessionWithPermissions(),
});
await db.insert(integrations).values([
@@ -47,11 +58,11 @@ describe("all should return all integrations", () => {
});
describe("byId should return an integration by id", () => {
it("should return an integration by id", async () => {
test("with full access should return an integration by id", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
session: null,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
await db.insert(integrations).values([
@@ -73,22 +84,22 @@ describe("byId should return an integration by id", () => {
expect(result.kind).toBe("plex");
});
it("should throw an error if the integration does not exist", async () => {
test("with full access should throw an error if the integration does not exist", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
session: null,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
const actAsync = async () => await caller.byId({ id: "2" });
await expect(actAsync()).rejects.toThrow("Integration not found");
});
it("should only return the public secret values", async () => {
test("with full access should only return the public secret values", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
session: null,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
await db.insert(integrations).values([
@@ -129,14 +140,38 @@ describe("byId should return an integration by id", () => {
const apiKey = expectToBeDefined(result.secrets.find((secret) => secret.kind === "apiKey"));
expect(apiKey.value).toBeNull();
});
});
describe("create should create a new integration", () => {
it("should create a new integration", async () => {
test("without full access should throw integration not found error", async () => {
// Arrange
const db = createDb();
const caller = integrationRouter.createCaller({
db,
session: null,
session: defaultSessionWithPermissions(["integration-interact-all"]),
});
await db.insert(integrations).values([
{
id: "1",
name: "Home assistant",
kind: "homeAssistant",
url: "http://homeassist.local",
},
]);
// Act
const actAsync = async () => await caller.byId({ id: "1" });
// Assert
await expect(actAsync()).rejects.toThrow("Integration not found");
});
});
describe("create should create a new integration", () => {
test("with create integration access should create a new integration", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
session: defaultSessionWithPermissions(["integration-create"]),
});
const input = {
name: "Jellyfin",
@@ -164,14 +199,35 @@ describe("create should create a new integration", () => {
expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
expect(dbSecret!.updatedAt).toEqual(fakeNow);
});
});
describe("update should update an integration", () => {
it("should update an integration", async () => {
test("without create integration access should throw permission error", async () => {
// Arrange
const db = createDb();
const caller = integrationRouter.createCaller({
db,
session: null,
session: defaultSessionWithPermissions(["integration-interact-all"]),
});
const input = {
name: "Jellyfin",
kind: "jellyfin" as const,
url: "http://jellyfin.local",
secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
};
// Act
const actAsync = async () => await caller.create(input);
// Assert
await expect(actAsync()).rejects.toThrow("Permission denied");
});
});
describe("update should update an integration", () => {
test("with full access should update an integration", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
const lastWeek = new Date("2023-06-24T00:00:00Z");
@@ -241,11 +297,11 @@ describe("update should update an integration", () => {
expect(apiKey.value).not.toEqual(input.secrets[2]!.value);
});
it("should throw an error if the integration does not exist", async () => {
test("with full access should throw an error if the integration does not exist", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
session: null,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
const actAsync = async () =>
@@ -257,14 +313,35 @@ describe("update should update an integration", () => {
});
await expect(actAsync()).rejects.toThrow("Integration not found");
});
});
describe("delete should delete an integration", () => {
it("should delete an integration", async () => {
test("without full access should throw permission error", async () => {
// Arrange
const db = createDb();
const caller = integrationRouter.createCaller({
db,
session: null,
session: defaultSessionWithPermissions(["integration-interact-all"]),
});
// Act
const actAsync = async () =>
await caller.update({
id: createId(),
name: "Pi Hole",
url: "http://hole.local",
secrets: [],
});
// Assert
await expect(actAsync()).rejects.toThrow("Integration not found");
});
});
describe("delete should delete an integration", () => {
test("with full access should delete an integration", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
const integrationId = createId();
@@ -291,4 +368,19 @@ describe("delete should delete an integration", () => {
const dbSecrets = await db.query.integrationSecrets.findMany();
expect(dbSecrets.length).toBe(0);
});
test("without full access should throw permission error", async () => {
// Arrange
const db = createDb();
const caller = integrationRouter.createCaller({
db,
session: defaultSessionWithPermissions(["integration-interact-all"]),
});
// Act
const actAsync = async () => await caller.delete({ id: createId() });
// Assert
await expect(actAsync()).rejects.toThrow("Integration not found");
});
});