feat: test integration connection (#669)

* feat: add test integration for pi-hole

* refactor: test integration for pi-hole

* fix: multiple secrets of same type could be used for integration creation

* fix: remove integration test connection test and add mock for test-connection function

* fix: add missing onUpdateFn to mysql integration secrets

* fix: format issues

* feat: add home assistant test connection

* fix: deepsource issues

* test: add system integration tests for test connection

* fix: add before all for pulling home assistant image

* test: add unit tests for handleTestConnectionResponseAsync

* test: add unit test for testConnectionAsync

* test: add mroe tests to integration-test-connection

* fix: deepsource issues

* fix: deepsource issue

* chore: address pull request feedback
This commit is contained in:
Meier Lukas
2024-06-22 21:02:04 +02:00
committed by GitHub
parent 92afd82d22
commit f92aeba403
30 changed files with 1138 additions and 550 deletions

View File

@@ -4,7 +4,7 @@ import { dockerRouter } from "./router/docker/docker-router";
import { groupRouter } from "./router/group";
import { homeRouter } from "./router/home";
import { iconsRouter } from "./router/icons";
import { integrationRouter } from "./router/integration";
import { integrationRouter } from "./router/integration/integration-router";
import { inviteRouter } from "./router/invite";
import { locationRouter } from "./router/location";
import { logRouter } from "./router/log";

View File

@@ -5,10 +5,11 @@ import type { Database } from "@homarr/db";
import { and, createId, eq } from "@homarr/db";
import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite";
import type { IntegrationSecretKind } from "@homarr/definitions";
import { getAllSecretKindOptions, integrationKinds, integrationSecretKindObject } from "@homarr/definitions";
import { integrationKinds, integrationSecretKindObject } from "@homarr/definitions";
import { validation } from "@homarr/validation";
import { createTRPCRouter, publicProcedure } from "../trpc";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { testConnectionAsync } from "./integration-test-connection";
export const integrationRouter = createTRPCRouter({
all: publicProcedure.query(async ({ ctx }) => {
@@ -60,6 +61,14 @@ export const integrationRouter = createTRPCRouter({
};
}),
create: publicProcedure.input(validation.integration.create).mutation(async ({ ctx, input }) => {
await testConnectionAsync({
id: "new",
name: input.name,
url: input.url,
kind: input.kind,
secrets: input.secrets,
});
const integrationId = createId();
await ctx.db.insert(integrations).values({
id: integrationId,
@@ -68,13 +77,14 @@ export const integrationRouter = createTRPCRouter({
kind: input.kind,
});
for (const secret of input.secrets) {
await ctx.db.insert(integrationSecrets).values({
kind: secret.kind,
value: encryptSecret(secret.value),
updatedAt: new Date(),
integrationId,
});
if (input.secrets.length >= 1) {
await ctx.db.insert(integrationSecrets).values(
input.secrets.map((secret) => ({
kind: secret.kind,
value: encryptSecret(secret.value),
integrationId,
})),
);
}
}),
update: publicProcedure.input(validation.integration.update).mutation(async ({ ctx, input }) => {
@@ -92,6 +102,17 @@ export const integrationRouter = createTRPCRouter({
});
}
await testConnectionAsync(
{
id: input.id,
name: input.name,
url: input.url,
kind: integration.kind,
secrets: input.secrets,
},
integration.secrets,
);
await ctx.db
.update(integrations)
.set({
@@ -100,15 +121,14 @@ export const integrationRouter = createTRPCRouter({
})
.where(eq(integrations.id, input.id));
const decryptedSecrets = integration.secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
}));
const changedSecrets = input.secrets.filter(
(secret): secret is { kind: IntegrationSecretKind; value: string } =>
secret.value !== null && // only update secrets that have a value
!decryptedSecrets.find((dSecret) => dSecret.kind === secret.kind && dSecret.value === secret.value),
!integration.secrets.find(
// Checked above
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
(dbSecret) => dbSecret.kind === secret.kind && dbSecret.value === encryptSecret(secret.value!),
),
);
if (changedSecrets.length > 0) {
@@ -118,7 +138,7 @@ export const integrationRouter = createTRPCRouter({
value: changedSecret.value,
kind: changedSecret.kind,
};
if (!decryptedSecrets.some((secret) => secret.kind === changedSecret.kind)) {
if (!integration.secrets.some((secret) => secret.kind === changedSecret.kind)) {
await addSecretAsync(ctx.db, secretInput);
} else {
await updateSecretAsync(ctx.db, secretInput);
@@ -140,71 +160,6 @@ export const integrationRouter = createTRPCRouter({
await ctx.db.delete(integrations).where(eq(integrations.id, input.id));
}),
testConnection: publicProcedure.input(validation.integration.testConnection).mutation(async ({ ctx, input }) => {
const secrets = input.secrets.filter((secret): secret is { kind: IntegrationSecretKind; value: string } =>
Boolean(secret.value),
);
// Find any matching secret kinds
let secretKinds = getAllSecretKindOptions(input.kind).find((secretKinds) =>
secretKinds.every((secretKind) => secrets.some((secret) => secret.kind === secretKind)),
);
if (!secretKinds && input.id === null) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "SECRETS_NOT_DEFINED",
});
}
if (!secretKinds && input.id !== null) {
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, input.id),
with: {
secrets: true,
},
});
if (!integration) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "SECRETS_NOT_DEFINED",
});
}
const decryptedSecrets = integration.secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
}));
// Add secrets that are not defined in the input from the database
for (const dbSecret of decryptedSecrets) {
if (!secrets.find((secret) => secret.kind === dbSecret.kind)) {
secrets.push({
kind: dbSecret.kind,
value: dbSecret.value,
});
}
}
secretKinds = getAllSecretKindOptions(input.kind).find((secretKinds) =>
secretKinds.every((secretKind) => secrets.some((secret) => secret.kind === secretKind)),
);
if (!secretKinds) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "SECRETS_NOT_DEFINED",
});
}
}
// TODO: actually test the connection
// Probably by calling a function on the integration class
// getIntegration(input.kind).testConnection(secrets)
// getIntegration(kind: IntegrationKind): Integration
// interface Integration {
// testConnection(): Promise<void>;
// }
}),
});
interface UpdateSecretInput {
@@ -217,7 +172,6 @@ const updateSecretAsync = async (db: Database, input: UpdateSecretInput) => {
.update(integrationSecrets)
.set({
value: encryptSecret(input.value),
updatedAt: new Date(),
})
.where(and(eq(integrationSecrets.integrationId, input.integrationId), eq(integrationSecrets.kind, input.kind)));
};
@@ -231,7 +185,6 @@ const addSecretAsync = async (db: Database, input: AddSecretInput) => {
await db.insert(integrationSecrets).values({
kind: input.kind,
value: encryptSecret(input.value),
updatedAt: new Date(),
integrationId: input.integrationId,
});
};

View File

@@ -0,0 +1,95 @@
import { decryptSecret } from "@homarr/common";
import type { Integration } from "@homarr/db/schema/sqlite";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import { getAllSecretKindOptions } from "@homarr/definitions";
import { integrationCreatorByKind, IntegrationTestConnectionError } from "@homarr/integrations";
type FormIntegration = Integration & {
secrets: {
kind: IntegrationSecretKind;
value: string | null;
}[];
};
export const testConnectionAsync = async (
integration: FormIntegration,
dbSecrets: {
kind: IntegrationSecretKind;
value: `${string}.${string}`;
}[] = [],
) => {
const formSecrets = integration.secrets
.filter((secret) => secret.value !== null)
.map((secret) => ({
...secret,
// We ensured above that the value is not null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
value: secret.value!,
source: "form" as const,
}));
const decryptedDbSecrets = dbSecrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
source: "db" as const,
}));
const sourcedSecrets = [...formSecrets, ...decryptedDbSecrets];
const secretKinds = getSecretKindOption(integration.kind, sourcedSecrets);
const filteredSecrets = secretKinds.map((kind) => {
const secrets = sourcedSecrets.filter((secret) => secret.kind === kind);
// Will never be undefined because of the check before
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (secrets.length === 1) return secrets[0]!;
// There will always be a matching secret because of the getSecretKindOption function
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return secrets.find((secret) => secret.source === "form") ?? secrets[0]!;
});
const integrationInstance = integrationCreatorByKind(integration.kind, {
id: integration.id,
name: integration.name,
url: integration.url,
decryptedSecrets: filteredSecrets,
});
await integrationInstance.testConnectionAsync();
};
interface SourcedIntegrationSecret {
kind: IntegrationSecretKind;
value: string;
source: "db" | "form";
}
const getSecretKindOption = (kind: IntegrationKind, sourcedSecrets: SourcedIntegrationSecret[]) => {
const matchingSecretKindOptions = getAllSecretKindOptions(kind).filter((secretKinds) =>
secretKinds.every((kind) => sourcedSecrets.some((secret) => secret.kind === kind)),
);
if (matchingSecretKindOptions.length === 0) {
throw new IntegrationTestConnectionError("secretNotDefined");
}
if (matchingSecretKindOptions.length === 1) {
// Will never be undefined because of the check above
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return matchingSecretKindOptions[0]!;
}
const onlyFormSecretsKindOptions = matchingSecretKindOptions.filter((secretKinds) =>
sourcedSecrets.filter((secret) => secretKinds.includes(secret.kind)).every((secret) => secret.source === "form"),
);
if (onlyFormSecretsKindOptions.length >= 1) {
// Will never be undefined because of the check above
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return onlyFormSecretsKindOptions[0]!;
}
// Will never be undefined because of the check above
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return matchingSecretKindOptions[0]!;
};

View File

@@ -7,12 +7,14 @@ import { createId } from "@homarr/db";
import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite";
import { createDb } from "@homarr/db/test";
import type { RouterInputs } from "../..";
import { integrationRouter } from "../integration";
import { expectToBeDefined } from "./helper";
import { integrationRouter } from "../../integration/integration-router";
import { expectToBeDefined } from "../helper";
// Mock the auth module to return an empty session
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
vi.mock("../../integration/integration-test-connection", () => ({
testConnectionAsync: async () => await Promise.resolve(undefined),
}));
describe("all should return all integrations", () => {
it("should return all integrations", async () => {
@@ -290,199 +292,3 @@ describe("delete should delete an integration", () => {
expect(dbSecrets.length).toBe(0);
});
});
describe("testConnection should test the connection to an integration", () => {
it.each([
[
"nzbGet" as const,
[
{ kind: "username" as const, value: null },
{ kind: "password" as const, value: "Password123!" },
],
],
[
"nzbGet" as const,
[
{ kind: "username" as const, value: "exampleUser" },
{ kind: "password" as const, value: null },
],
],
["sabNzbd" as const, [{ kind: "apiKey" as const, value: null }]],
[
"sabNzbd" as const,
[
{ kind: "username" as const, value: "exampleUser" },
{ kind: "password" as const, value: "Password123!" },
],
],
])("should fail when a required secret is missing when creating %s integration", async (kind, secrets) => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
session: null,
});
const input: RouterInputs["integration"]["testConnection"] = {
id: null,
kind,
url: `http://${kind}.local`,
secrets,
};
const actAsync = async () => await caller.testConnection(input);
await expect(actAsync()).rejects.toThrow("SECRETS_NOT_DEFINED");
});
it.each([
[
"nzbGet" as const,
[
{ kind: "username" as const, value: "exampleUser" },
{ kind: "password" as const, value: "Password123!" },
],
],
["sabNzbd" as const, [{ kind: "apiKey" as const, value: "1234567890" }]],
])(
"should be successful when all required secrets are defined for creation of %s integration",
async (kind, secrets) => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
session: null,
});
const input: RouterInputs["integration"]["testConnection"] = {
id: null,
kind,
url: `http://${kind}.local`,
secrets,
};
const actAsync = async () => await caller.testConnection(input);
await expect(actAsync()).resolves.toBeUndefined();
},
);
it("should be successful when all required secrets are defined for updating an nzbGet integration", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
session: null,
});
const input: RouterInputs["integration"]["testConnection"] = {
id: createId(),
kind: "nzbGet",
url: "http://nzbGet.local",
secrets: [
{ kind: "username", value: "exampleUser" },
{ kind: "password", value: "Password123!" },
],
};
const actAsync = async () => await caller.testConnection(input);
await expect(actAsync()).resolves.toBeUndefined();
});
it("should be successful when overriding one of the secrets for an existing nzbGet integration", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
session: null,
});
const integrationId = createId();
await db.insert(integrations).values({
id: integrationId,
name: "NZBGet",
kind: "nzbGet",
url: "http://nzbGet.local",
});
await db.insert(integrationSecrets).values([
{
kind: "username",
value: encryptSecret("exampleUser"),
integrationId,
updatedAt: new Date(),
},
{
kind: "password",
value: encryptSecret("Password123!"),
integrationId,
updatedAt: new Date(),
},
]);
const input: RouterInputs["integration"]["testConnection"] = {
id: integrationId,
kind: "nzbGet",
url: "http://nzbGet.local",
secrets: [
{ kind: "username", value: "newUser" },
{ kind: "password", value: null },
],
};
const actAsync = async () => await caller.testConnection(input);
await expect(actAsync()).resolves.toBeUndefined();
});
it("should fail when a required secret is missing for an existing nzbGet integration", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
session: null,
});
const integrationId = createId();
await db.insert(integrations).values({
id: integrationId,
name: "NZBGet",
kind: "nzbGet",
url: "http://nzbGet.local",
});
await db.insert(integrationSecrets).values([
{
kind: "username",
value: encryptSecret("exampleUser"),
integrationId,
updatedAt: new Date(),
},
]);
const input: RouterInputs["integration"]["testConnection"] = {
id: integrationId,
kind: "nzbGet",
url: "http://nzbGet.local",
secrets: [
{ kind: "username", value: "newUser" },
{ kind: "apiKey", value: "1234567890" },
],
};
const actAsync = async () => await caller.testConnection(input);
await expect(actAsync()).rejects.toThrow("SECRETS_NOT_DEFINED");
});
it("should fail when the updating integration does not exist", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
session: null,
});
const actAsync = async () =>
await caller.testConnection({
id: createId(),
kind: "nzbGet",
url: "http://nzbGet.local",
secrets: [
{ kind: "username", value: null },
{ kind: "password", value: "Password123!" },
],
});
await expect(actAsync()).rejects.toThrow("SECRETS_NOT_DEFINED");
});
});

View File

@@ -0,0 +1,253 @@
import { describe, expect, test, vi } from "vitest";
import * as homarrDefinitions from "@homarr/definitions";
import * as homarrIntegrations from "@homarr/integrations";
import { testConnectionAsync } from "../../integration/integration-test-connection";
vi.mock("@homarr/common", async (importActual) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const actual = await importActual<typeof import("@homarr/common")>();
return {
...actual,
decryptSecret: (value: string) => value.split(".")[0],
};
});
describe("testConnectionAsync should run test connection of integration", () => {
test("with input of only form secrets matching api key kind it should use form apiKey", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreatorByKind");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue({
testConnectionAsync: async () => await Promise.resolve(),
} as homarrIntegrations.PiHoleIntegration);
optionsSpy.mockReturnValue([["apiKey"]]);
const integration = {
id: "new",
name: "Pi Hole",
url: "http://pi.hole",
kind: "piHole" as const,
secrets: [
{
kind: "apiKey" as const,
value: "secret",
},
],
};
// Act
await testConnectionAsync(integration);
// Assert
expect(factorySpy).toHaveBeenCalledWith("piHole", {
id: "new",
name: "Pi Hole",
url: "http://pi.hole",
decryptedSecrets: [
expect.objectContaining({
kind: "apiKey",
value: "secret",
}),
],
});
});
test("with input of only null form secrets and the required db secrets matching api key kind it should use db apiKey", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreatorByKind");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue({
testConnectionAsync: async () => await Promise.resolve(),
} as homarrIntegrations.PiHoleIntegration);
optionsSpy.mockReturnValue([["apiKey"]]);
const integration = {
id: "new",
name: "Pi Hole",
url: "http://pi.hole",
kind: "piHole" as const,
secrets: [
{
kind: "apiKey" as const,
value: null,
},
],
};
const dbSecrets = [
{
kind: "apiKey" as const,
value: "dbSecret.encrypted" as const,
},
];
// Act
await testConnectionAsync(integration, dbSecrets);
// Assert
expect(factorySpy).toHaveBeenCalledWith("piHole", {
id: "new",
name: "Pi Hole",
url: "http://pi.hole",
decryptedSecrets: [
expect.objectContaining({
kind: "apiKey",
value: "dbSecret",
}),
],
});
});
test("with input of form and db secrets matching api key kind it should use form apiKey", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreatorByKind");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue({
testConnectionAsync: async () => await Promise.resolve(),
} as homarrIntegrations.PiHoleIntegration);
optionsSpy.mockReturnValue([["apiKey"]]);
const integration = {
id: "new",
name: "Pi Hole",
url: "http://pi.hole",
kind: "piHole" as const,
secrets: [
{
kind: "apiKey" as const,
value: "secret",
},
],
};
const dbSecrets = [
{
kind: "apiKey" as const,
value: "dbSecret.encrypted" as const,
},
];
// Act
await testConnectionAsync(integration, dbSecrets);
// Assert
expect(factorySpy).toHaveBeenCalledWith("piHole", {
id: "new",
name: "Pi Hole",
url: "http://pi.hole",
decryptedSecrets: [
expect.objectContaining({
kind: "apiKey",
value: "secret",
}),
],
});
});
test("with input of form apiKey and db secrets for username and password it should use form apiKey when both is allowed", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreatorByKind");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue({
testConnectionAsync: async () => await Promise.resolve(),
} as homarrIntegrations.PiHoleIntegration);
optionsSpy.mockReturnValue([["username", "password"], ["apiKey"]]);
const integration = {
id: "new",
name: "Pi Hole",
url: "http://pi.hole",
kind: "piHole" as const,
secrets: [
{
kind: "apiKey" as const,
value: "secret",
},
],
};
const dbSecrets = [
{
kind: "username" as const,
value: "dbUsername.encrypted" as const,
},
{
kind: "password" as const,
value: "dbPassword.encrypted" as const,
},
];
// Act
await testConnectionAsync(integration, dbSecrets);
// Assert
expect(factorySpy).toHaveBeenCalledWith("piHole", {
id: "new",
name: "Pi Hole",
url: "http://pi.hole",
decryptedSecrets: [
expect.objectContaining({
kind: "apiKey",
value: "secret",
}),
],
});
});
test("with input of null form apiKey and db secrets for username and password it should use db username and password when both is allowed", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreatorByKind");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue({
testConnectionAsync: async () => await Promise.resolve(),
} as homarrIntegrations.PiHoleIntegration);
optionsSpy.mockReturnValue([["username", "password"], ["apiKey"]]);
const integration = {
id: "new",
name: "Pi Hole",
url: "http://pi.hole",
kind: "piHole" as const,
secrets: [
{
kind: "apiKey" as const,
value: null,
},
],
};
const dbSecrets = [
{
kind: "username" as const,
value: "dbUsername.encrypted" as const,
},
{
kind: "password" as const,
value: "dbPassword.encrypted" as const,
},
];
// Act
await testConnectionAsync(integration, dbSecrets);
// Assert
expect(factorySpy).toHaveBeenCalledWith("piHole", {
id: "new",
name: "Pi Hole",
url: "http://pi.hole",
decryptedSecrets: [
expect.objectContaining({
kind: "username",
value: "dbUsername",
}),
expect.objectContaining({
kind: "password",
value: "dbPassword",
}),
],
});
});
});

View File

@@ -10,6 +10,7 @@ import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import type { Session } from "@homarr/auth";
import { FlattenError } from "@homarr/common";
import { db } from "@homarr/db";
import type { GroupPermissionKey } from "@homarr/definitions";
import { logger } from "@homarr/log";
@@ -52,6 +53,7 @@ const t = initTRPC.context<typeof createTRPCContext>().create({
data: {
...shape.data,
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
error: error.cause instanceof FlattenError ? error.cause.flatten() : null,
},
}),
});

View File

@@ -9,3 +9,16 @@ export const extractErrorMessage = (error: unknown) => {
return "Unknown error";
};
export abstract class FlattenError extends Error {
constructor(
message: string,
private flattenResult: Record<string, unknown>,
) {
super(message);
}
public flatten(): Record<string, unknown> {
return this.flattenResult;
}
}

View File

@@ -1,5 +1,9 @@
export const appendPath = (url: URL | string, path: string) => {
const newUrl = new URL(url);
newUrl.pathname += path;
newUrl.pathname = removeTrailingSlash(newUrl.pathname) + path;
return newUrl;
};
const removeTrailingSlash = (path: string) => {
return path.at(-1) === "/" ? path.substring(0, path.length - 1) : path;
};

View File

@@ -141,7 +141,9 @@ export const integrationSecrets = mysqlTable(
{
kind: varchar("kind", { length: 16 }).$type<IntegrationSecretKind>().notNull(),
value: text("value").$type<`${string}.${string}`>().notNull(),
updatedAt: timestamp("updated_at").notNull(),
updatedAt: timestamp("updated_at")
.$onUpdateFn(() => new Date())
.notNull(),
integrationId: varchar("integration_id", { length: 64 })
.notNull()
.references(() => integrations.id, { onDelete: "cascade" }),

View File

@@ -144,7 +144,9 @@ export const integrationSecrets = sqliteTable(
{
kind: text("kind").$type<IntegrationSecretKind>().notNull(),
value: text("value").$type<`${string}.${string}`>().notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" })
.$onUpdateFn(() => new Date())
.notNull(),
integrationId: text("integration_id")
.notNull()
.references(() => integrations.id, { onDelete: "cascade" }),

View File

@@ -4,6 +4,7 @@
"version": "0.1.0",
"exports": {
".": "./index.ts",
"./client": "./src/client.ts",
"./types": "./src/types.ts"
},
"typesVersions": {
@@ -25,7 +26,8 @@
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0"
"@homarr/common": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -0,0 +1,16 @@
import type { IntegrationKind } from "@homarr/definitions";
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
import { PiHoleIntegration } from "../pi-hole/pi-hole-integration";
import type { IntegrationInput } from "./integration";
export const integrationCreatorByKind = (kind: IntegrationKind, integration: IntegrationInput) => {
switch (kind) {
case "piHole":
return new PiHoleIntegration(integration);
case "homeAssistant":
return new HomeAssistantIntegration(integration);
default:
throw new Error(`Unknown integration kind ${kind}`);
}
};

View File

@@ -1,16 +1,25 @@
import { extractErrorMessage } from "@homarr/common";
import type { IntegrationSecretKind } from "@homarr/definitions";
import { logger } from "@homarr/log";
import type { TranslationObject } from "@homarr/translation";
import { z } from "@homarr/validation";
import { IntegrationTestConnectionError } from "./test-connection-error";
import type { IntegrationSecret } from "./types";
const causeSchema = z.object({
code: z.string(),
});
export interface IntegrationInput {
id: string;
name: string;
url: string;
decryptedSecrets: IntegrationSecret[];
}
export abstract class Integration {
constructor(
protected integration: {
id: string;
name: string;
url: string;
decryptedSecrets: IntegrationSecret[];
},
) {}
constructor(protected integration: IntegrationInput) {}
protected getSecretValue(kind: IntegrationSecretKind) {
const secret = this.integration.decryptedSecrets.find((secret) => secret.kind === kind);
@@ -19,4 +28,87 @@ export abstract class Integration {
}
return secret.value;
}
/**
* Test the connection to the integration
* @throws {IntegrationTestConnectionError} if the connection fails
*/
public abstract testConnectionAsync(): Promise<void>;
protected async handleTestConnectionResponseAsync({
queryFunctionAsync,
handleResponseAsync,
}: {
queryFunctionAsync: () => Promise<Response>;
handleResponseAsync?: (response: Response) => Promise<void>;
}): Promise<void> {
const response = await queryFunctionAsync().catch((error) => {
if (error instanceof Error) {
const cause = causeSchema.safeParse(error.cause);
if (!cause.success) {
logger.error("Failed to test connection", error);
throw new IntegrationTestConnectionError("commonError", extractErrorMessage(error));
}
if (cause.data.code === "ENOTFOUND") {
logger.error("Failed to test connection: Domain not found");
throw new IntegrationTestConnectionError("domainNotFound");
}
if (cause.data.code === "ECONNREFUSED") {
logger.error("Failed to test connection: Connection refused");
throw new IntegrationTestConnectionError("connectionRefused");
}
if (cause.data.code === "ECONNABORTED") {
logger.error("Failed to test connection: Connection aborted");
throw new IntegrationTestConnectionError("connectionAborted");
}
}
logger.error("Failed to test connection", error);
throw new IntegrationTestConnectionError("commonError", extractErrorMessage(error));
});
if (response.status >= 400) {
logger.error(`Failed to test connection with status code ${response.status}`);
throwErrorByStatusCode(response.status);
}
await handleResponseAsync?.(response);
}
}
export interface TestConnectionError {
key: Exclude<keyof TranslationObject["integration"]["testConnection"]["notification"], "success">;
message?: string;
}
export type TestConnectionResult =
| {
success: false;
error: TestConnectionError;
}
| {
success: true;
};
const throwErrorByStatusCode = (statusCode: number) => {
switch (statusCode) {
case 400:
throw new IntegrationTestConnectionError("badRequest");
case 401:
throw new IntegrationTestConnectionError("unauthorized");
case 403:
throw new IntegrationTestConnectionError("forbidden");
case 404:
throw new IntegrationTestConnectionError("notFound");
case 500:
throw new IntegrationTestConnectionError("internalServerError");
case 503:
throw new IntegrationTestConnectionError("serviceUnavailable");
default:
throw new IntegrationTestConnectionError("commonError");
}
};

View File

@@ -0,0 +1,26 @@
import { FlattenError } from "@homarr/common";
import { z } from "@homarr/validation";
import type { TestConnectionError } from "./integration";
export class IntegrationTestConnectionError extends FlattenError {
constructor(
public key: TestConnectionError["key"],
public detailMessage?: string,
) {
super("Checking integration connection failed", { key, message: detailMessage });
}
}
const schema = z.object({
key: z.custom<TestConnectionError["key"]>((value) => z.string().parse(value)),
message: z.string().optional(),
});
export const convertIntegrationTestConnectionError = (error: unknown) => {
const result = schema.safeParse(error);
if (!result.success) {
return;
}
return result.data;
};

View File

@@ -0,0 +1 @@
export { convertIntegrationTestConnectionError } from "./base/test-connection-error";

View File

@@ -5,13 +5,9 @@ import { Integration } from "../base/integration";
import { entityStateSchema } from "./homeassistant-types";
export class HomeAssistantIntegration extends Integration {
async getEntityStateAsync(entityId: string) {
public async getEntityStateAsync(entityId: string) {
try {
const response = await fetch(appendPath(this.integration.url, `/states/${entityId}`), {
headers: {
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
},
});
const response = await this.getAsync(`/api/states/${entityId}`);
const body = (await response.json()) as unknown;
if (!response.ok) {
logger.warn(`Response did not indicate success`);
@@ -29,17 +25,12 @@ export class HomeAssistantIntegration extends Integration {
}
}
async triggerAutomationAsync(entityId: string) {
public async triggerAutomationAsync(entityId: string) {
try {
const response = await fetch(appendPath(this.integration.url, "/services/automation/trigger"), {
headers: {
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
},
body: JSON.stringify({
entity_id: entityId,
}),
method: "POST",
const response = await this.postAsync("/api/services/automation/trigger", {
entity_id: entityId,
});
return response.ok;
} catch (err) {
logger.error(`Failed to fetch from '${this.integration.url}': ${err as string}`);
@@ -53,21 +44,61 @@ export class HomeAssistantIntegration extends Integration {
* @param entityId - The ID of the entity to toggle.
* @returns A boolean indicating whether the toggle action was successful.
*/
async triggerToggleAsync(entityId: string) {
public async triggerToggleAsync(entityId: string) {
try {
const response = await fetch(appendPath(this.integration.url, "/services/homeassistant/toggle"), {
headers: {
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
},
body: JSON.stringify({
entity_id: entityId,
}),
method: "POST",
const response = await this.postAsync("/api/services/homeassistant/toggle", {
entity_id: entityId,
});
return response.ok;
} catch (err) {
logger.error(`Failed to fetch from '${this.integration.url}': ${err as string}`);
return false;
}
}
public async testConnectionAsync(): Promise<void> {
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
return await this.getAsync("/api/config");
},
});
}
/**
* Makes a GET request to the Home Assistant API.
* It includes the authorization header with the API key.
* @param path full path to the API endpoint
* @returns the response from the API
*/
private async getAsync(path: `/api/${string}`) {
return await fetch(appendPath(this.integration.url, path), {
headers: this.getAuthHeaders(),
});
}
/**
* Makes a POST request to the Home Assistant API.
* It includes the authorization header with the API key.
* @param path full path to the API endpoint
* @param body the body of the request
* @returns the response from the API
*/
private async postAsync(path: `/api/${string}`, body: Record<string, string>) {
return await fetch(appendPath(this.integration.url, path), {
headers: this.getAuthHeaders(),
body: JSON.stringify(body),
method: "POST",
});
}
/**
* Returns the headers required for authorization.
* @returns the authorization headers
*/
private getAuthHeaders() {
return {
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
};
}
}

View File

@@ -1,2 +1,7 @@
// General integrations
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
// Helpers
export { IntegrationTestConnectionError } from "./base/test-connection-error";
export { integrationCreatorByKind } from "./base/creator";

View File

@@ -1,10 +1,11 @@
import { Integration } from "../base/integration";
import { IntegrationTestConnectionError } from "../base/test-connection-error";
import type { DnsHoleSummaryIntegration } from "../interfaces/dns-hole-summary/dns-hole-summary-integration";
import type { DnsHoleSummary } from "../interfaces/dns-hole-summary/dns-hole-summary-types";
import { summaryResponseSchema } from "./pi-hole-types";
export class PiHoleIntegration extends Integration implements DnsHoleSummaryIntegration {
async getSummaryAsync(): Promise<DnsHoleSummary> {
public async getSummaryAsync(): Promise<DnsHoleSummary> {
const apiKey = super.getSecretValue("apiKey");
const response = await fetch(`${this.integration.url}/admin/api.php?summaryRaw&auth=${apiKey}`);
if (!response.ok) {
@@ -28,4 +29,24 @@ export class PiHoleIntegration extends Integration implements DnsHoleSummaryInte
dnsQueriesToday: result.data.dns_queries_today,
};
}
public async testConnectionAsync(): Promise<void> {
const apiKey = super.getSecretValue("apiKey");
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
return await fetch(`${this.integration.url}/admin/api.php?status&auth=${apiKey}`);
},
handleResponseAsync: async (response) => {
try {
const result = (await response.json()) as unknown;
if (typeof result === "object" && result !== null && "status" in result) return;
} catch (error) {
throw new IntegrationTestConnectionError("invalidJson");
}
throw new IntegrationTestConnectionError("invalidCredentials");
},
});
}
}

View File

@@ -0,0 +1,249 @@
import { describe, expect, test } from "vitest";
import { IntegrationTestConnectionError } from "../src";
import { Integration } from "../src/base/integration";
type HandleResponseProps = Parameters<Integration["handleTestConnectionResponseAsync"]>[0];
class BaseIntegrationMock extends Integration {
public async fakeTestConnectionAsync(props: HandleResponseProps): Promise<void> {
await super.handleTestConnectionResponseAsync(props);
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
public async testConnectionAsync(): Promise<void> {}
}
describe("Base integration", () => {
describe("handleTestConnectionResponseAsync", () => {
test("With no cause error should throw IntegrationTestConnectionError with key commonError", async () => {
// Arrange
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
const errorMessage = "The error message";
const props: HandleResponseProps = {
async queryFunctionAsync() {
return await Promise.reject(new Error(errorMessage));
},
};
// Act
const actPromise = integration.fakeTestConnectionAsync(props);
// Assert
await expect(actPromise).rejects.toHaveProperty("key", "commonError");
await expect(actPromise).rejects.toHaveProperty("detailMessage", errorMessage);
});
test("With cause ENOTFOUND should throw IntegrationTestConnectionError with key domainNotFound", async () => {
// Arrange
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
const props: HandleResponseProps = {
async queryFunctionAsync() {
return await Promise.reject(new Error("Error", { cause: { code: "ENOTFOUND" } }));
},
};
// Act
const actAsync = async () => await integration.fakeTestConnectionAsync(props);
// Assert
await expect(actAsync()).rejects.toHaveProperty("key", "domainNotFound");
});
test("With cause ENOTFOUND should throw IntegrationTestConnectionError with key connectionRefused", async () => {
// Arrange
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
const props: HandleResponseProps = {
async queryFunctionAsync() {
return await Promise.reject(new Error("Error", { cause: { code: "ECONNREFUSED" } }));
},
};
// Act
const actAsync = async () => await integration.fakeTestConnectionAsync(props);
// Assert
await expect(actAsync()).rejects.toHaveProperty("key", "connectionRefused");
});
test("With cause ENOTFOUND should throw IntegrationTestConnectionError with key connectionAborted", async () => {
// Arrange
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
const props: HandleResponseProps = {
async queryFunctionAsync() {
return await Promise.reject(new Error("Error", { cause: { code: "ECONNABORTED" } }));
},
};
// Act
const actAsync = async () => await integration.fakeTestConnectionAsync(props);
// Assert
await expect(actAsync()).rejects.toHaveProperty("key", "connectionAborted");
});
test("With not handled cause error should throw IntegrationTestConnectionError with key commonError", async () => {
// Arrange
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
const errorMessage = "The error message";
const props: HandleResponseProps = {
async queryFunctionAsync() {
return await Promise.reject(new Error(errorMessage));
},
};
// Act
const actPromise = integration.fakeTestConnectionAsync(props);
// Assert
await expect(actPromise).rejects.toHaveProperty("key", "commonError");
await expect(actPromise).rejects.toHaveProperty("detailMessage", errorMessage);
});
test("With response status code 400 should throw IntegrationTestConnectionError with key badRequest", async () => {
// Arrange
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
const props: HandleResponseProps = {
async queryFunctionAsync() {
return await Promise.resolve(new Response(null, { status: 400 }));
},
};
// Act
const actAsync = async () => await integration.fakeTestConnectionAsync(props);
// Assert
await expect(actAsync()).rejects.toHaveProperty("key", "badRequest");
});
test("With response status code 401 should throw IntegrationTestConnectionError with key unauthorized", async () => {
// Arrange
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
const props: HandleResponseProps = {
async queryFunctionAsync() {
return await Promise.resolve(new Response(null, { status: 401 }));
},
};
// Act
const actAsync = async () => await integration.fakeTestConnectionAsync(props);
// Assert
await expect(actAsync()).rejects.toHaveProperty("key", "unauthorized");
});
test("With response status code 403 should throw IntegrationTestConnectionError with key forbidden", async () => {
// Arrange
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
const props: HandleResponseProps = {
async queryFunctionAsync() {
return await Promise.resolve(new Response(null, { status: 403 }));
},
};
// Act
const actAsync = async () => await integration.fakeTestConnectionAsync(props);
// Assert
await expect(actAsync()).rejects.toHaveProperty("key", "forbidden");
});
test("With response status code 404 should throw IntegrationTestConnectionError with key notFound", async () => {
// Arrange
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
const props: HandleResponseProps = {
async queryFunctionAsync() {
return await Promise.resolve(new Response(null, { status: 404 }));
},
};
// Act
const actAsync = async () => await integration.fakeTestConnectionAsync(props);
// Assert
await expect(actAsync()).rejects.toHaveProperty("key", "notFound");
});
test("With response status code 500 should throw IntegrationTestConnectionError with key internalServerError", async () => {
// Arrange
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
const props: HandleResponseProps = {
async queryFunctionAsync() {
return await Promise.resolve(new Response(null, { status: 500 }));
},
};
// Act
const actAsync = async () => await integration.fakeTestConnectionAsync(props);
// Assert
await expect(actAsync()).rejects.toHaveProperty("key", "internalServerError");
});
test("With response status code 503 should throw IntegrationTestConnectionError with key serviceUnavailable", async () => {
// Arrange
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
const props: HandleResponseProps = {
async queryFunctionAsync() {
return await Promise.resolve(new Response(null, { status: 503 }));
},
};
// Act
const actAsync = async () => await integration.fakeTestConnectionAsync(props);
// Assert
await expect(actAsync()).rejects.toHaveProperty("key", "serviceUnavailable");
});
test("With response status code 418 (or any other unhandled code) should throw IntegrationTestConnectionError with key commonError", async () => {
// Arrange
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
const props: HandleResponseProps = {
async queryFunctionAsync() {
return await Promise.resolve(new Response(null, { status: 418 }));
},
};
// Act
const actAsync = async () => await integration.fakeTestConnectionAsync(props);
// Assert
await expect(actAsync()).rejects.toHaveProperty("key", "commonError");
});
test("Errors from handleResponseAsync should be thrown", async () => {
// Arrange
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
const errorMessage = "The error message";
const props: HandleResponseProps = {
async queryFunctionAsync() {
return await Promise.resolve(new Response(null, { status: 200 }));
},
async handleResponseAsync() {
return await Promise.reject(new IntegrationTestConnectionError("commonError", errorMessage));
},
};
// Act
const actPromise = integration.fakeTestConnectionAsync(props);
// Assert
await expect(actPromise).rejects.toHaveProperty("key", "commonError");
await expect(actPromise).rejects.toHaveProperty("detailMessage", errorMessage);
});
});
});

View File

@@ -0,0 +1,81 @@
import type { StartedTestContainer } from "testcontainers";
import { GenericContainer, getContainerRuntimeClient, ImageName, Wait } from "testcontainers";
import { beforeAll, describe, expect, test } from "vitest";
import { HomeAssistantIntegration, IntegrationTestConnectionError } from "../src";
const DEFAULT_API_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJkNjQwY2VjNDFjOGU0NGM5YmRlNWQ4ZmFjMjUzYWViZiIsImlhdCI6MTcxODQ3MTE1MSwiZXhwIjoyMDMzODMxMTUxfQ.uQCZ5FZTokipa6N27DtFhLHkwYEXU1LZr0fsVTryL2Q";
const IMAGE_NAME = "ghcr.io/home-assistant/home-assistant:stable";
describe("Home Assistant integration", () => {
beforeAll(async () => {
const containerRuntimeClient = await getContainerRuntimeClient();
await containerRuntimeClient.image.pull(ImageName.fromString(IMAGE_NAME));
}, 100_000);
test("Test connection should work", async () => {
// Arrange
const startedContainer = await prepareHomeAssistantContainerAsync();
const homeAssistantIntegration = createHomeAssistantIntegration(startedContainer);
// Act
const actAsync = async () => await homeAssistantIntegration.testConnectionAsync();
// Assert
await expect(actAsync()).resolves.not.toThrow();
// Cleanup
await startedContainer.stop();
}, 20_000); // Timeout of 20 seconds
test("Test connection should fail with wrong credentials", async () => {
// Arrange
const startedContainer = await prepareHomeAssistantContainerAsync();
const homeAssistantIntegration = createHomeAssistantIntegration(startedContainer, "wrong-api-key");
// Act
const actAsync = async () => await homeAssistantIntegration.testConnectionAsync();
// Assert
await expect(actAsync()).rejects.toThrow(IntegrationTestConnectionError);
// Cleanup
await startedContainer.stop();
}, 20_000); // Timeout of 20 seconds
});
const prepareHomeAssistantContainerAsync = async () => {
const homeAssistantContainer = createHomeAssistantContainer();
const startedContainer = await homeAssistantContainer.start();
await startedContainer.exec(["unzip", "-o", "/tmp/config.zip", "-d", "/config"]);
await startedContainer.restart();
return startedContainer;
};
const createHomeAssistantContainer = () => {
return new GenericContainer(IMAGE_NAME)
.withCopyFilesToContainer([
{
source: __dirname + "/volumes/home-assistant-config.zip",
target: "/tmp/config.zip",
},
])
.withPrivilegedMode()
.withExposedPorts(8123)
.withWaitStrategy(Wait.forHttp("/", 8123));
};
const createHomeAssistantIntegration = (container: StartedTestContainer, apiKeyOverride?: string) => {
return new HomeAssistantIntegration({
id: "1",
decryptedSecrets: [
{
kind: "apiKey",
value: apiKeyOverride ?? DEFAULT_API_KEY,
},
],
name: "Home assistant",
url: `http://${container.getHost()}:${container.getMappedPort(8123)}`,
});
};

View File

@@ -25,6 +25,36 @@ describe("Pi-hole integration", () => {
// Cleanup
await piholeContainer.stop();
}, 20_000); // Timeout of 20 seconds
test("testConnectionAsync should not throw", async () => {
// Arrange
const piholeContainer = await createPiHoleContainer(DEFAULT_PASSWORD).start();
const piHoleIntegration = createPiHoleIntegration(piholeContainer, DEFAULT_API_KEY);
// Act
const actAsync = async () => await piHoleIntegration.testConnectionAsync();
// Assert
await expect(actAsync()).resolves.not.toThrow();
// Cleanup
await piholeContainer.stop();
}, 20_000); // Timeout of 20 seconds
test("testConnectionAsync should throw with wrong credentials", async () => {
// Arrange
const piholeContainer = await createPiHoleContainer(DEFAULT_PASSWORD).start();
const piHoleIntegration = createPiHoleIntegration(piholeContainer, "wrong-api-key");
// Act
const actAsync = async () => await piHoleIntegration.testConnectionAsync();
// Assert
await expect(actAsync()).rejects.toThrow();
// Cleanup
await piholeContainer.stop();
}, 20_000); // Timeout of 20 seconds
});
const createPiHoleContainer = (password: string) => {

View File

@@ -389,7 +389,10 @@ export default {
create: "New integration",
},
testConnection: {
action: "Test connection",
action: {
create: "Test connection and create",
edit: "Test connection and save",
},
alertNotice: "The Save button is enabled once a successful connection is established",
notification: {
success: {
@@ -400,7 +403,7 @@ export default {
title: "Invalid URL",
message: "The URL is invalid",
},
notAllSecretsProvided: {
secretNotDefined: {
title: "Missing credentials",
message: "Not all credentials were provided",
},
@@ -412,6 +415,50 @@ export default {
title: "Connection failed",
message: "The connection could not be established",
},
badRequest: {
title: "Bad request",
message: "The request was malformed",
},
unauthorized: {
title: "Unauthorized",
message: "Probably wrong credentials",
},
forbidden: {
title: "Forbidden",
message: "Probably missing permissions",
},
notFound: {
title: "Not found",
message: "Probably wrong url or path",
},
internalServerError: {
title: "Internal server error",
message: "The server encountered an error",
},
serviceUnavailable: {
title: "Service unavailable",
message: "The server is currently unavailable",
},
connectionAborted: {
title: "Connection aborted",
message: "The connection was aborted",
},
domainNotFound: {
title: "Domain not found",
message: "The domain could not be found",
},
connectionRefused: {
title: "Connection refused",
message: "The connection was refused",
},
invalidJson: {
title: "Invalid JSON",
message: "The response was not valid JSON",
},
wrongPath: {
title: "Wrong path",
message: "The path is probably not correct",
},
},
},
secrets: {