feat(integrations): add app linking (#4338)

This commit is contained in:
Meier Lukas
2025-10-24 20:21:27 +02:00
committed by GitHub
parent 6f0b5d7e04
commit 172db0e3f9
47 changed files with 6791 additions and 158 deletions

View File

@@ -4,7 +4,7 @@ import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server";
import { integrations, integrationSecrets } from "@homarr/db/schema";
import { apps, integrations, integrationSecrets } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import type { GroupPermissionKey } from "@homarr/definitions";
@@ -251,6 +251,102 @@ describe("create should create a new integration", () => {
);
});
test("with create integration access should create a new integration with new linked app", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create", "app-create"]),
});
const input = {
name: "Jellyfin",
kind: "jellyfin" as const,
url: "http://jellyfin.local",
secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
attemptSearchEngineCreation: false,
app: {
name: "Jellyfin",
description: null,
pingUrl: "http://jellyfin.local",
href: "https://jellyfin.home",
iconUrl: "logo.png",
},
};
const fakeNow = new Date("2023-07-01T00:00:00Z");
vi.useFakeTimers();
vi.setSystemTime(fakeNow);
await caller.create(input);
vi.useRealTimers();
const dbIntegration = await db.query.integrations.findFirst({
with: {
app: true,
},
});
const dbSecret = await db.query.integrationSecrets.findFirst();
expect(dbIntegration).toBeDefined();
expect(dbIntegration!.name).toBe(input.name);
expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url);
expect(dbIntegration!.app!.name).toBe(input.app.name);
expect(dbIntegration!.app!.pingUrl).toBe(input.app.pingUrl);
expect(dbIntegration!.app!.href).toBe(input.app.href);
expect(dbIntegration!.app!.iconUrl).toBe(input.app.iconUrl);
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
expect(dbSecret).toBeDefined();
expect(dbSecret!.kind).toBe(input.secrets[0]!.kind);
expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
expect(dbSecret!.updatedAt).toEqual(fakeNow);
});
test("with create integration access should create a new integration with existing linked app", async () => {
const db = createDb();
const appId = createId();
await db.insert(apps).values({
id: appId,
name: "Existing Jellyfin",
iconUrl: "logo.png",
});
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create"]),
});
const input = {
name: "Jellyfin",
kind: "jellyfin" as const,
url: "http://jellyfin.local",
secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
attemptSearchEngineCreation: false,
app: {
id: appId,
},
};
const fakeNow = new Date("2023-07-01T00:00:00Z");
vi.useFakeTimers();
vi.setSystemTime(fakeNow);
await caller.create(input);
vi.useRealTimers();
const dbIntegration = await db.query.integrations.findFirst();
const dbSecret = await db.query.integrationSecrets.findFirst();
expect(dbIntegration).toBeDefined();
expect(dbIntegration!.name).toBe(input.name);
expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url);
expect(dbIntegration!.appId).toBe(appId);
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
expect(dbSecret).toBeDefined();
expect(dbSecret!.kind).toBe(input.secrets[0]!.kind);
expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
expect(dbSecret!.updatedAt).toEqual(fakeNow);
});
test("without create integration access should throw permission error", async () => {
// Arrange
const db = createDb();
@@ -273,6 +369,36 @@ describe("create should create a new integration", () => {
// Assert
await expect(actAsync()).rejects.toThrow("Permission denied");
});
test("without create app access should throw permission error with new linked app", async () => {
// Arrange
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create"]),
});
const input = {
name: "Jellyfin",
kind: "jellyfin" as const,
url: "http://jellyfin.local",
secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
attemptSearchEngineCreation: false,
app: {
name: "Jellyfin",
description: null,
href: "https://jellyfin.home",
iconUrl: "logo.png",
pingUrl: null,
},
};
// Act
const actAsync = async () => await caller.create(input);
// Assert
await expect(actAsync()).rejects.toThrow("Permission denied");
});
});
describe("update should update an integration", () => {
@@ -285,6 +411,7 @@ describe("update should update an integration", () => {
});
const lastWeek = new Date("2023-06-24T00:00:00Z");
const appId = createId();
const integrationId = createId();
const toInsert = {
id: integrationId,
@@ -293,6 +420,11 @@ describe("update should update an integration", () => {
url: "http://hole.local",
};
await db.insert(apps).values({
id: appId,
name: "Previous",
iconUrl: "logo.png",
});
await db.insert(integrations).values(toInsert);
const usernameToInsert = {
@@ -320,6 +452,7 @@ describe("update should update an integration", () => {
{ kind: "password" as const, value: null },
{ kind: "apiKey" as const, value: "1234567890" },
],
appId,
};
const fakeNow = new Date("2023-07-01T00:00:00Z");
@@ -335,6 +468,7 @@ describe("update should update an integration", () => {
expect(dbIntegration!.name).toBe(input.name);
expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url);
expect(dbIntegration!.appId).toBe(appId);
expect(dbSecrets.length).toBe(3);
const username = expectToBeDefined(dbSecrets.find((secret) => secret.kind === "username"));
@@ -365,6 +499,7 @@ describe("update should update an integration", () => {
name: "Pi Hole",
url: "http://hole.local",
secrets: [],
appId: null,
});
await expect(actAsync()).rejects.toThrow("Integration not found");
});
@@ -385,6 +520,7 @@ describe("update should update an integration", () => {
name: "Pi Hole",
url: "http://hole.local",
secrets: [],
appId: null,
});
// Assert

View File

@@ -55,6 +55,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
value: "secret",
}),
],
externalUrl: null,
});
});
@@ -104,6 +105,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
value: "dbSecret",
}),
],
externalUrl: null,
});
});
@@ -153,6 +155,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
value: "secret",
}),
],
externalUrl: null,
});
});
@@ -206,6 +209,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
value: "secret",
}),
],
externalUrl: null,
});
});
@@ -263,6 +267,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
value: "dbPassword",
}),
],
externalUrl: null,
});
});
@@ -336,6 +341,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
value: "privateKey",
}),
],
externalUrl: null,
});
});
});