diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/_integration-secret-card.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-card.tsx similarity index 97% rename from apps/nextjs/src/app/[locale]/manage/integrations/_integration-secret-card.tsx rename to apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-card.tsx index b23fd148a..f90b1d40f 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/_integration-secret-card.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-card.tsx @@ -12,7 +12,7 @@ import type { RouterOutputs } from "@homarr/api"; import { integrationSecretKindObject } from "@homarr/definitions"; import { useI18n } from "@homarr/translation/client"; -import { integrationSecretIcons } from "./_integration-secret-icons"; +import { integrationSecretIcons } from "./integration-secret-icons"; dayjs.extend(relativeTime); diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/_integration-secret-icons.ts b/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts similarity index 100% rename from apps/nextjs/src/app/[locale]/manage/integrations/_integration-secret-icons.ts rename to apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/_integration-secret-inputs.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-inputs.tsx similarity index 92% rename from apps/nextjs/src/app/[locale]/manage/integrations/_integration-secret-inputs.tsx rename to apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-inputs.tsx index f73e8516b..45f5b3156 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/_integration-secret-inputs.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-inputs.tsx @@ -7,7 +7,7 @@ import { integrationSecretKindObject } from "@homarr/definitions"; import type { IntegrationSecretKind } from "@homarr/definitions"; import { useI18n } from "@homarr/translation/client"; -import { integrationSecretIcons } from "./_integration-secret-icons"; +import { integrationSecretIcons } from "./integration-secret-icons"; interface IntegrationSecretInputProps { withAsterisk?: boolean; @@ -50,7 +50,7 @@ const PrivateSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => } /> diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/_integration-test-connection.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/_integration-test-connection.tsx deleted file mode 100644 index 2ae512edd..000000000 --- a/apps/nextjs/src/app/[locale]/manage/integrations/_integration-test-connection.tsx +++ /dev/null @@ -1,129 +0,0 @@ -"use client"; - -import { useRef, useState } from "react"; -import { Alert, Anchor, Group, Loader } from "@mantine/core"; -import { IconCheck, IconInfoCircle, IconX } from "@tabler/icons-react"; - -import type { RouterInputs } from "@homarr/api"; -import { clientApi } from "@homarr/api/client"; -import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; -import { useI18n, useScopedI18n } from "@homarr/translation/client"; - -interface UseTestConnectionDirtyProps { - defaultDirty: boolean; - initialFormValue: { - url: string; - secrets: { kind: string; value: string | null }[]; - }; -} - -export const useTestConnectionDirty = ({ defaultDirty, initialFormValue }: UseTestConnectionDirtyProps) => { - const [isDirty, setIsDirty] = useState(defaultDirty); - const prevFormValueRef = useRef(initialFormValue); - - return { - onValuesChange: (values: typeof initialFormValue) => { - if (isDirty) return; - - // If relevant values changed, set dirty - if ( - prevFormValueRef.current.url !== values.url || - !prevFormValueRef.current.secrets - .map((secret) => secret.value) - .every((secretValue, index) => values.secrets[index]?.value === secretValue) - ) { - setIsDirty(true); - return; - } - - // If relevant values changed back to last tested, set not dirty - setIsDirty(false); - }, - isDirty, - removeDirty: () => { - prevFormValueRef.current = initialFormValue; - setIsDirty(false); - }, - }; -}; - -interface TestConnectionProps { - isDirty: boolean; - removeDirty: () => void; - integration: RouterInputs["integration"]["testConnection"] & { name: string }; -} - -export const TestConnection = ({ integration, removeDirty, isDirty }: TestConnectionProps) => { - const t = useScopedI18n("integration.testConnection"); - const { mutateAsync, ...mutation } = clientApi.integration.testConnection.useMutation(); - - return ( - - { - await mutateAsync(integration, { - onSuccess: () => { - removeDirty(); - showSuccessNotification({ - title: t("notification.success.title"), - message: t("notification.success.message"), - }); - }, - onError: (error) => { - if (error.data?.zodError?.fieldErrors.url) { - showErrorNotification({ - title: t("notification.invalidUrl.title"), - message: t("notification.invalidUrl.message"), - }); - return; - } - - if (error.message === "SECRETS_NOT_DEFINED") { - showErrorNotification({ - title: t("notification.notAllSecretsProvided.title"), - message: t("notification.notAllSecretsProvided.message"), - }); - return; - } - - showErrorNotification({ - title: t("notification.commonError.title"), - message: t("notification.commonError.message"), - }); - }, - }); - }} - > - {t("action")} - - - - ); -}; - -interface TestConnectionIconProps { - isDirty: boolean; - isPending: boolean; - isSuccess: boolean; - isError: boolean; - size: number; -} - -const TestConnectionIcon = ({ isDirty, isPending, isSuccess, isError, size }: TestConnectionIconProps) => { - if (isPending) return ; - if (isDirty) return null; - if (isSuccess) return ; - if (isError) return ; - return null; -}; - -export const TestConnectionNoticeAlert = () => { - const t = useI18n(); - return ( - }> - {t("integration.testConnection.alertNotice")} - - ); -}; diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form.tsx index c93826e92..847479ea3 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form.tsx @@ -8,6 +8,7 @@ import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions"; import { useZodForm } from "@homarr/form"; +import { convertIntegrationTestConnectionError } from "@homarr/integrations/client"; import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; @@ -15,9 +16,8 @@ import type { z } from "@homarr/validation"; import { validation } from "@homarr/validation"; import { revalidatePathActionAsync } from "~/app/revalidatePathAction"; -import { SecretCard } from "../../_integration-secret-card"; -import { IntegrationSecretInput } from "../../_integration-secret-inputs"; -import { TestConnection, TestConnectionNoticeAlert, useTestConnectionDirty } from "../../_integration-test-connection"; +import { SecretCard } from "../../_components/secrets/integration-secret-card"; +import { IntegrationSecretInput } from "../../_components/secrets/integration-secret-inputs"; interface EditIntegrationForm { integration: RouterOutputs["integration"]["byId"]; @@ -30,30 +30,23 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => { getAllSecretKindOptions(integration.kind).find((secretKinds) => integration.secrets.every((secret) => secretKinds.includes(secret.kind)), ) ?? getDefaultSecretKinds(integration.kind); - const initialFormValues = { - name: integration.name, - url: integration.url, - secrets: secretsKinds.map((kind) => ({ - kind, - value: integration.secrets.find((secret) => secret.kind === kind)?.value ?? "", - })), - }; - const { isDirty, onValuesChange, removeDirty } = useTestConnectionDirty({ - defaultDirty: true, - initialFormValue: initialFormValues, - }); const router = useRouter(); const form = useZodForm(validation.integration.update.omit({ id: true }), { - initialValues: initialFormValues, - onValuesChange, + initialValues: { + name: integration.name, + url: integration.url, + secrets: secretsKinds.map((kind) => ({ + kind, + value: integration.secrets.find((secret) => secret.kind === kind)?.value ?? "", + })), + }, }); const { mutateAsync, isPending } = clientApi.integration.update.useMutation(); const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret])); const handleSubmitAsync = async (values: FormType) => { - if (isDirty) return; await mutateAsync( { id: integration.id, @@ -71,7 +64,19 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => { }); void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations")); }, - onError: () => { + onError: (error) => { + const testConnectionError = convertIntegrationTestConnectionError(error.data?.error); + + if (testConnectionError) { + showErrorNotification({ + title: t(`integration.testConnection.notification.${testConnectionError.key}.title`), + message: testConnectionError.message + ? testConnectionError.message + : t(`integration.testConnection.notification.${testConnectionError.key}.message`), + }); + return; + } + showErrorNotification({ title: t("integration.page.edit.notification.error.title"), message: t("integration.page.edit.notification.error.message"), @@ -84,8 +89,6 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => { return (
void handleSubmitAsync(values))}> - - @@ -98,18 +101,18 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion secret={secretsMap.get(kind)!} onCancel={() => - new Promise((res) => { + new Promise((resolve) => { // When nothing changed, just close the secret card if ((form.values.secrets[index]?.value ?? "") === (secretsMap.get(kind)?.value ?? "")) { - return res(true); + return resolve(true); } openConfirmModal({ title: t("integration.secrets.reset.title"), children: t("integration.secrets.reset.message"), - onCancel: () => res(false), + onCancel: () => resolve(false), onConfirm: () => { form.setFieldValue(`secrets.${index}.value`, secretsMap.get(kind)?.value ?? ""); - res(true); + resolve(true); }, }); }) @@ -126,24 +129,13 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => { - - - - - - + + + diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx index 524b2efb9..d7ab653e1 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx @@ -10,13 +10,13 @@ import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions import { getAllSecretKindOptions } from "@homarr/definitions"; import type { UseFormReturnType } from "@homarr/form"; import { useZodForm } from "@homarr/form"; +import { convertIntegrationTestConnectionError } from "@homarr/integrations/client"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; import type { z } from "@homarr/validation"; import { validation } from "@homarr/validation"; -import { IntegrationSecretInput } from "../_integration-secret-inputs"; -import { TestConnection, TestConnectionNoticeAlert, useTestConnectionDirty } from "../_integration-test-connection"; +import { IntegrationSecretInput } from "../_components/secrets/integration-secret-inputs"; import { revalidatePathActionAsync } from "../../../../revalidatePathAction"; interface NewIntegrationFormProps { @@ -28,27 +28,20 @@ interface NewIntegrationFormProps { export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => { const t = useI18n(); const secretKinds = getAllSecretKindOptions(searchParams.kind); - const initialFormValues = { - name: searchParams.name ?? "", - url: searchParams.url ?? "", - secrets: secretKinds[0].map((kind) => ({ - kind, - value: "", - })), - }; - const { isDirty, onValuesChange, removeDirty } = useTestConnectionDirty({ - defaultDirty: true, - initialFormValue: initialFormValues, - }); const router = useRouter(); const form = useZodForm(validation.integration.create.omit({ kind: true }), { - initialValues: initialFormValues, - onValuesChange, + initialValues: { + name: searchParams.name ?? "", + url: searchParams.url ?? "", + secrets: secretKinds[0].map((kind) => ({ + kind, + value: "", + })), + }, }); const { mutateAsync, isPending } = clientApi.integration.create.useMutation(); const handleSubmitAsync = async (values: FormType) => { - if (isDirty) return; await mutateAsync( { kind: searchParams.kind, @@ -62,7 +55,19 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => }); void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations")); }, - onError: () => { + onError: (error) => { + const testConnectionError = convertIntegrationTestConnectionError(error.data?.error); + + if (testConnectionError) { + showErrorNotification({ + title: t(`integration.testConnection.notification.${testConnectionError.key}.title`), + message: testConnectionError.message + ? testConnectionError.message + : t(`integration.testConnection.notification.${testConnectionError.key}.message`), + }); + return; + } + showErrorNotification({ title: t("integration.page.create.notification.error.title"), message: t("integration.page.create.notification.error.message"), @@ -75,8 +80,6 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => return (
void handleSubmitAsync(value))}> - - @@ -95,25 +98,13 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => - - - - - - - + + + diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index 239be6f09..ce71e53b8 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -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"; diff --git a/packages/api/src/router/integration.ts b/packages/api/src/router/integration/integration-router.ts similarity index 61% rename from packages/api/src/router/integration.ts rename to packages/api/src/router/integration/integration-router.ts index c7b333673..611c288d1 100644 --- a/packages/api/src/router/integration.ts +++ b/packages/api/src/router/integration/integration-router.ts @@ -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; - // } - }), }); 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, }); }; diff --git a/packages/api/src/router/integration/integration-test-connection.ts b/packages/api/src/router/integration/integration-test-connection.ts new file mode 100644 index 000000000..ba8ffa2dd --- /dev/null +++ b/packages/api/src/router/integration/integration-test-connection.ts @@ -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]!; +}; diff --git a/packages/api/src/router/test/integration.spec.ts b/packages/api/src/router/test/integration/integration-router.spec.ts similarity index 60% rename from packages/api/src/router/test/integration.spec.ts rename to packages/api/src/router/test/integration/integration-router.spec.ts index 5bbbcc4b4..711cfcb20 100644 --- a/packages/api/src/router/test/integration.spec.ts +++ b/packages/api/src/router/test/integration/integration-router.spec.ts @@ -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"); - }); -}); diff --git a/packages/api/src/router/test/integration/integration-test-connection.spec.ts b/packages/api/src/router/test/integration/integration-test-connection.spec.ts new file mode 100644 index 000000000..5118f526c --- /dev/null +++ b/packages/api/src/router/test/integration/integration-test-connection.spec.ts @@ -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(); + + 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", + }), + ], + }); + }); +}); diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index 0248720d3..3969b204a 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -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().create({ data: { ...shape.data, zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, + error: error.cause instanceof FlattenError ? error.cause.flatten() : null, }, }), }); diff --git a/packages/common/src/error.ts b/packages/common/src/error.ts index ddc5f93ea..96adb35e0 100644 --- a/packages/common/src/error.ts +++ b/packages/common/src/error.ts @@ -9,3 +9,16 @@ export const extractErrorMessage = (error: unknown) => { return "Unknown error"; }; + +export abstract class FlattenError extends Error { + constructor( + message: string, + private flattenResult: Record, + ) { + super(message); + } + + public flatten(): Record { + return this.flattenResult; + } +} diff --git a/packages/common/src/url.ts b/packages/common/src/url.ts index f99fe099c..5c46343ff 100644 --- a/packages/common/src/url.ts +++ b/packages/common/src/url.ts @@ -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; +}; diff --git a/packages/db/schema/mysql.ts b/packages/db/schema/mysql.ts index d53a9ec30..22692da00 100644 --- a/packages/db/schema/mysql.ts +++ b/packages/db/schema/mysql.ts @@ -141,7 +141,9 @@ export const integrationSecrets = mysqlTable( { kind: varchar("kind", { length: 16 }).$type().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" }), diff --git a/packages/db/schema/sqlite.ts b/packages/db/schema/sqlite.ts index f7c3c87e5..ff913f264 100644 --- a/packages/db/schema/sqlite.ts +++ b/packages/db/schema/sqlite.ts @@ -144,7 +144,9 @@ export const integrationSecrets = sqliteTable( { kind: text("kind").$type().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" }), diff --git a/packages/integrations/package.json b/packages/integrations/package.json index 939b278e8..87b4f5ed9 100644 --- a/packages/integrations/package.json +++ b/packages/integrations/package.json @@ -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", diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts new file mode 100644 index 000000000..50aee2be8 --- /dev/null +++ b/packages/integrations/src/base/creator.ts @@ -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}`); + } +}; diff --git a/packages/integrations/src/base/integration.ts b/packages/integrations/src/base/integration.ts index e8612736a..69b346e23 100644 --- a/packages/integrations/src/base/integration.ts +++ b/packages/integrations/src/base/integration.ts @@ -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; + + protected async handleTestConnectionResponseAsync({ + queryFunctionAsync, + handleResponseAsync, + }: { + queryFunctionAsync: () => Promise; + handleResponseAsync?: (response: Response) => Promise; + }): Promise { + 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; + 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"); + } +}; diff --git a/packages/integrations/src/base/test-connection-error.ts b/packages/integrations/src/base/test-connection-error.ts new file mode 100644 index 000000000..8a8067f0e --- /dev/null +++ b/packages/integrations/src/base/test-connection-error.ts @@ -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((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; +}; diff --git a/packages/integrations/src/client.ts b/packages/integrations/src/client.ts new file mode 100644 index 000000000..d62df98bb --- /dev/null +++ b/packages/integrations/src/client.ts @@ -0,0 +1 @@ +export { convertIntegrationTestConnectionError } from "./base/test-connection-error"; diff --git a/packages/integrations/src/homeassistant/homeassistant-integration.ts b/packages/integrations/src/homeassistant/homeassistant-integration.ts index 267b3b957..47f10ce3e 100644 --- a/packages/integrations/src/homeassistant/homeassistant-integration.ts +++ b/packages/integrations/src/homeassistant/homeassistant-integration.ts @@ -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 { + 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) { + 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")}`, + }; + } } diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 1804d2124..fd241bae3 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -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"; diff --git a/packages/integrations/src/pi-hole/pi-hole-integration.ts b/packages/integrations/src/pi-hole/pi-hole-integration.ts index 4b309c07b..c0c61a26c 100644 --- a/packages/integrations/src/pi-hole/pi-hole-integration.ts +++ b/packages/integrations/src/pi-hole/pi-hole-integration.ts @@ -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 { + public async getSummaryAsync(): Promise { 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 { + 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"); + }, + }); + } } diff --git a/packages/integrations/test/base.spec.ts b/packages/integrations/test/base.spec.ts new file mode 100644 index 000000000..9ef1f3861 --- /dev/null +++ b/packages/integrations/test/base.spec.ts @@ -0,0 +1,249 @@ +import { describe, expect, test } from "vitest"; + +import { IntegrationTestConnectionError } from "../src"; +import { Integration } from "../src/base/integration"; + +type HandleResponseProps = Parameters[0]; + +class BaseIntegrationMock extends Integration { + public async fakeTestConnectionAsync(props: HandleResponseProps): Promise { + await super.handleTestConnectionResponseAsync(props); + } + + // eslint-disable-next-line @typescript-eslint/no-empty-function + public async testConnectionAsync(): Promise {} +} + +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); + }); + }); +}); diff --git a/packages/integrations/test/home-assistant.spec.ts b/packages/integrations/test/home-assistant.spec.ts new file mode 100644 index 000000000..76d8446e6 --- /dev/null +++ b/packages/integrations/test/home-assistant.spec.ts @@ -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)}`, + }); +}; diff --git a/packages/integrations/test/pi-hole.spec.ts b/packages/integrations/test/pi-hole.spec.ts index 829f8f891..23f8ef425 100644 --- a/packages/integrations/test/pi-hole.spec.ts +++ b/packages/integrations/test/pi-hole.spec.ts @@ -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) => { diff --git a/packages/integrations/test/volumes/home-assistant-config.zip b/packages/integrations/test/volumes/home-assistant-config.zip new file mode 100644 index 000000000..e66c5c76b Binary files /dev/null and b/packages/integrations/test/volumes/home-assistant-config.zip differ diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index 8468fe44e..ef44dba64 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -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: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f24a19dc2..e07c70686 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -758,6 +758,9 @@ importers: '@homarr/log': specifier: workspace:^0.1.0 version: link:../log + '@homarr/translation': + specifier: workspace:^0.1.0 + version: link:../translation '@homarr/validation': specifier: workspace:^0.1.0 version: link:../validation @@ -3171,20 +3174,20 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - bare-events@2.3.1: - resolution: {integrity: sha512-sJnSOTVESURZ61XgEleqmP255T6zTYwHPwE4r6SssIh0U9/uDvfpdoJYpVUerJJZH2fueO+CdT8ZT+OC/7aZDA==} + bare-events@2.4.2: + resolution: {integrity: sha512-qMKFd2qG/36aA4GwvKq8MxnPgCQAmBWmSyLWsJcbn8v03wvIPQ/hG1Ms8bPzndZxMDoHpxez5VOS+gC9Yi24/Q==} bare-fs@2.3.1: resolution: {integrity: sha512-W/Hfxc/6VehXlsgFtbB5B4xFcsCl+pAh30cYhoFyXErf6oGrwjh8SwiPAdHgpmWonKuYpZgGywN0SXt7dgsADA==} - bare-os@2.3.0: - resolution: {integrity: sha512-oPb8oMM1xZbhRQBngTgpcQ5gXw6kjOaRsSWsIeNyRxGed2w/ARyP7ScBYpWR1qfX2E5rS3gBw6OWcSQo+s+kUg==} + bare-os@2.4.0: + resolution: {integrity: sha512-v8DTT08AS/G0F9xrhyLtepoo9EJBJ85FRSMbu1pQUlAf6A8T0tEEQGMVObWeqpjhSPXsE0VGlluFBJu2fdoTNg==} bare-path@2.1.3: resolution: {integrity: sha512-lh/eITfU8hrj9Ru5quUp0Io1kJWIk1bTjzo7JH1P5dWmQ2EL4hFUlfI8FonAhSlgIfhn63p84CDY/x+PisgcXA==} - bare-stream@2.1.2: - resolution: {integrity: sha512-az/7TFOh4Gk9Tqs1/xMFq5FuFoeZ9hZ3orsM2x69u8NXVUDXZnpdhG8mZY/Pv6DF954MGn+iIt4rFrG34eQsvg==} + bare-stream@2.1.3: + resolution: {integrity: sha512-tiDAH9H/kP+tvNO5sczyn9ZAA7utrSMobyDchsnyyXBuUe2FSQWbxhtuHB8jwpHYYevVo2UJpcmvvjrbHboUUQ==} base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -6794,7 +6797,7 @@ snapshots: '@babel/highlight@7.23.4': dependencies: - '@babel/helper-validator-identifier': 7.22.20 + '@babel/helper-validator-identifier': 7.24.6 chalk: 2.4.2 js-tokens: 4.0.0 @@ -8515,25 +8518,25 @@ snapshots: balanced-match@1.0.2: {} - bare-events@2.3.1: + bare-events@2.4.2: optional: true bare-fs@2.3.1: dependencies: - bare-events: 2.3.1 + bare-events: 2.4.2 bare-path: 2.1.3 - bare-stream: 2.1.2 + bare-stream: 2.1.3 optional: true - bare-os@2.3.0: + bare-os@2.4.0: optional: true bare-path@2.1.3: dependencies: - bare-os: 2.3.0 + bare-os: 2.4.0 optional: true - bare-stream@2.1.2: + bare-stream@2.1.3: dependencies: streamx: 2.18.0 optional: true @@ -11540,7 +11543,7 @@ snapshots: queue-tick: 1.0.1 text-decoder: 1.1.0 optionalDependencies: - bare-events: 2.3.1 + bare-events: 2.4.2 string-width@4.2.3: dependencies: