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:
@@ -12,7 +12,7 @@ import type { RouterOutputs } from "@homarr/api";
|
|||||||
import { integrationSecretKindObject } from "@homarr/definitions";
|
import { integrationSecretKindObject } from "@homarr/definitions";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { integrationSecretIcons } from "./_integration-secret-icons";
|
import { integrationSecretIcons } from "./integration-secret-icons";
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ import { integrationSecretKindObject } from "@homarr/definitions";
|
|||||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { integrationSecretIcons } from "./_integration-secret-icons";
|
import { integrationSecretIcons } from "./integration-secret-icons";
|
||||||
|
|
||||||
interface IntegrationSecretInputProps {
|
interface IntegrationSecretInputProps {
|
||||||
withAsterisk?: boolean;
|
withAsterisk?: boolean;
|
||||||
@@ -50,7 +50,7 @@ const PrivateSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) =>
|
|||||||
<PasswordInput
|
<PasswordInput
|
||||||
{...props}
|
{...props}
|
||||||
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
|
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
|
||||||
description={t(`integration.secrets.secureNotice`)}
|
description={t("integration.secrets.secureNotice")}
|
||||||
w="100%"
|
w="100%"
|
||||||
leftSection={<Icon size={20} stroke={1.5} />}
|
leftSection={<Icon size={20} stroke={1.5} />}
|
||||||
/>
|
/>
|
||||||
@@ -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 (
|
|
||||||
<Group>
|
|
||||||
<Anchor
|
|
||||||
type="button"
|
|
||||||
component="button"
|
|
||||||
onClick={async () => {
|
|
||||||
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")}
|
|
||||||
</Anchor>
|
|
||||||
<TestConnectionIcon isDirty={isDirty} {...mutation} size={20} />
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface TestConnectionIconProps {
|
|
||||||
isDirty: boolean;
|
|
||||||
isPending: boolean;
|
|
||||||
isSuccess: boolean;
|
|
||||||
isError: boolean;
|
|
||||||
size: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TestConnectionIcon = ({ isDirty, isPending, isSuccess, isError, size }: TestConnectionIconProps) => {
|
|
||||||
if (isPending) return <Loader color="blue" size={size} />;
|
|
||||||
if (isDirty) return null;
|
|
||||||
if (isSuccess) return <IconCheck size={size} stroke={1.5} color="green" />;
|
|
||||||
if (isError) return <IconX size={size} stroke={1.5} color="red" />;
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TestConnectionNoticeAlert = () => {
|
|
||||||
const t = useI18n();
|
|
||||||
return (
|
|
||||||
<Alert variant="light" color="yellow" title="Test Connection" icon={<IconInfoCircle />}>
|
|
||||||
{t("integration.testConnection.alertNotice")}
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -8,6 +8,7 @@ import type { RouterOutputs } from "@homarr/api";
|
|||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions";
|
import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
|
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
@@ -15,9 +16,8 @@ import type { z } from "@homarr/validation";
|
|||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||||
import { SecretCard } from "../../_integration-secret-card";
|
import { SecretCard } from "../../_components/secrets/integration-secret-card";
|
||||||
import { IntegrationSecretInput } from "../../_integration-secret-inputs";
|
import { IntegrationSecretInput } from "../../_components/secrets/integration-secret-inputs";
|
||||||
import { TestConnection, TestConnectionNoticeAlert, useTestConnectionDirty } from "../../_integration-test-connection";
|
|
||||||
|
|
||||||
interface EditIntegrationForm {
|
interface EditIntegrationForm {
|
||||||
integration: RouterOutputs["integration"]["byId"];
|
integration: RouterOutputs["integration"]["byId"];
|
||||||
@@ -30,30 +30,23 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
|||||||
getAllSecretKindOptions(integration.kind).find((secretKinds) =>
|
getAllSecretKindOptions(integration.kind).find((secretKinds) =>
|
||||||
integration.secrets.every((secret) => secretKinds.includes(secret.kind)),
|
integration.secrets.every((secret) => secretKinds.includes(secret.kind)),
|
||||||
) ?? getDefaultSecretKinds(integration.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 router = useRouter();
|
||||||
const form = useZodForm(validation.integration.update.omit({ id: true }), {
|
const form = useZodForm(validation.integration.update.omit({ id: true }), {
|
||||||
initialValues: initialFormValues,
|
initialValues: {
|
||||||
onValuesChange,
|
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 { mutateAsync, isPending } = clientApi.integration.update.useMutation();
|
||||||
|
|
||||||
const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
|
const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
|
||||||
|
|
||||||
const handleSubmitAsync = async (values: FormType) => {
|
const handleSubmitAsync = async (values: FormType) => {
|
||||||
if (isDirty) return;
|
|
||||||
await mutateAsync(
|
await mutateAsync(
|
||||||
{
|
{
|
||||||
id: integration.id,
|
id: integration.id,
|
||||||
@@ -71,7 +64,19 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
|||||||
});
|
});
|
||||||
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
|
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({
|
showErrorNotification({
|
||||||
title: t("integration.page.edit.notification.error.title"),
|
title: t("integration.page.edit.notification.error.title"),
|
||||||
message: t("integration.page.edit.notification.error.message"),
|
message: t("integration.page.edit.notification.error.message"),
|
||||||
@@ -84,8 +89,6 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TestConnectionNoticeAlert />
|
|
||||||
|
|
||||||
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
|
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
|
||||||
|
|
||||||
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
|
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
|
||||||
@@ -98,18 +101,18 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
secret={secretsMap.get(kind)!}
|
secret={secretsMap.get(kind)!}
|
||||||
onCancel={() =>
|
onCancel={() =>
|
||||||
new Promise((res) => {
|
new Promise((resolve) => {
|
||||||
// When nothing changed, just close the secret card
|
// When nothing changed, just close the secret card
|
||||||
if ((form.values.secrets[index]?.value ?? "") === (secretsMap.get(kind)?.value ?? "")) {
|
if ((form.values.secrets[index]?.value ?? "") === (secretsMap.get(kind)?.value ?? "")) {
|
||||||
return res(true);
|
return resolve(true);
|
||||||
}
|
}
|
||||||
openConfirmModal({
|
openConfirmModal({
|
||||||
title: t("integration.secrets.reset.title"),
|
title: t("integration.secrets.reset.title"),
|
||||||
children: t("integration.secrets.reset.message"),
|
children: t("integration.secrets.reset.message"),
|
||||||
onCancel: () => res(false),
|
onCancel: () => resolve(false),
|
||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
form.setFieldValue(`secrets.${index}.value`, secretsMap.get(kind)?.value ?? "");
|
form.setFieldValue(`secrets.${index}.value`, secretsMap.get(kind)?.value ?? "");
|
||||||
res(true);
|
resolve(true);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
@@ -126,24 +129,13 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Fieldset>
|
</Fieldset>
|
||||||
|
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="end" align="center">
|
||||||
<TestConnection
|
<Button variant="default" component={Link} href="/manage/integrations">
|
||||||
isDirty={isDirty}
|
{t("common.action.backToOverview")}
|
||||||
removeDirty={removeDirty}
|
</Button>
|
||||||
integration={{
|
<Button type="submit" loading={isPending}>
|
||||||
id: integration.id,
|
{t("integration.testConnection.action.edit")}
|
||||||
kind: integration.kind,
|
</Button>
|
||||||
...form.values,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Group>
|
|
||||||
<Button variant="default" component={Link} href="/manage/integrations">
|
|
||||||
{t("common.action.backToOverview")}
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" loading={isPending} disabled={isDirty}>
|
|
||||||
{t("common.action.save")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions
|
|||||||
import { getAllSecretKindOptions } from "@homarr/definitions";
|
import { getAllSecretKindOptions } from "@homarr/definitions";
|
||||||
import type { UseFormReturnType } from "@homarr/form";
|
import type { UseFormReturnType } from "@homarr/form";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
|
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
import type { z } from "@homarr/validation";
|
import type { z } from "@homarr/validation";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
import { IntegrationSecretInput } from "../_integration-secret-inputs";
|
import { IntegrationSecretInput } from "../_components/secrets/integration-secret-inputs";
|
||||||
import { TestConnection, TestConnectionNoticeAlert, useTestConnectionDirty } from "../_integration-test-connection";
|
|
||||||
import { revalidatePathActionAsync } from "../../../../revalidatePathAction";
|
import { revalidatePathActionAsync } from "../../../../revalidatePathAction";
|
||||||
|
|
||||||
interface NewIntegrationFormProps {
|
interface NewIntegrationFormProps {
|
||||||
@@ -28,27 +28,20 @@ interface NewIntegrationFormProps {
|
|||||||
export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => {
|
export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const secretKinds = getAllSecretKindOptions(searchParams.kind);
|
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 router = useRouter();
|
||||||
const form = useZodForm(validation.integration.create.omit({ kind: true }), {
|
const form = useZodForm(validation.integration.create.omit({ kind: true }), {
|
||||||
initialValues: initialFormValues,
|
initialValues: {
|
||||||
onValuesChange,
|
name: searchParams.name ?? "",
|
||||||
|
url: searchParams.url ?? "",
|
||||||
|
secrets: secretKinds[0].map((kind) => ({
|
||||||
|
kind,
|
||||||
|
value: "",
|
||||||
|
})),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const { mutateAsync, isPending } = clientApi.integration.create.useMutation();
|
const { mutateAsync, isPending } = clientApi.integration.create.useMutation();
|
||||||
|
|
||||||
const handleSubmitAsync = async (values: FormType) => {
|
const handleSubmitAsync = async (values: FormType) => {
|
||||||
if (isDirty) return;
|
|
||||||
await mutateAsync(
|
await mutateAsync(
|
||||||
{
|
{
|
||||||
kind: searchParams.kind,
|
kind: searchParams.kind,
|
||||||
@@ -62,7 +55,19 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
|||||||
});
|
});
|
||||||
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
|
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({
|
showErrorNotification({
|
||||||
title: t("integration.page.create.notification.error.title"),
|
title: t("integration.page.create.notification.error.title"),
|
||||||
message: t("integration.page.create.notification.error.message"),
|
message: t("integration.page.create.notification.error.message"),
|
||||||
@@ -75,8 +80,6 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
|
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TestConnectionNoticeAlert />
|
|
||||||
|
|
||||||
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
|
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
|
||||||
|
|
||||||
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
|
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
|
||||||
@@ -95,25 +98,13 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Fieldset>
|
</Fieldset>
|
||||||
|
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="end" align="center">
|
||||||
<TestConnection
|
<Button variant="default" component={Link} href="/manage/integrations">
|
||||||
isDirty={isDirty}
|
{t("common.action.backToOverview")}
|
||||||
removeDirty={removeDirty}
|
</Button>
|
||||||
integration={{
|
<Button type="submit" loading={isPending}>
|
||||||
id: null,
|
{t("integration.testConnection.action.create")}
|
||||||
kind: searchParams.kind,
|
</Button>
|
||||||
...form.values,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Group>
|
|
||||||
<Button variant="default" component={Link} href="/manage/integrations">
|
|
||||||
{t("common.action.backToOverview")}
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" loading={isPending} disabled={isDirty}>
|
|
||||||
{t("common.action.create")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { dockerRouter } from "./router/docker/docker-router";
|
|||||||
import { groupRouter } from "./router/group";
|
import { groupRouter } from "./router/group";
|
||||||
import { homeRouter } from "./router/home";
|
import { homeRouter } from "./router/home";
|
||||||
import { iconsRouter } from "./router/icons";
|
import { iconsRouter } from "./router/icons";
|
||||||
import { integrationRouter } from "./router/integration";
|
import { integrationRouter } from "./router/integration/integration-router";
|
||||||
import { inviteRouter } from "./router/invite";
|
import { inviteRouter } from "./router/invite";
|
||||||
import { locationRouter } from "./router/location";
|
import { locationRouter } from "./router/location";
|
||||||
import { logRouter } from "./router/log";
|
import { logRouter } from "./router/log";
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import type { Database } from "@homarr/db";
|
|||||||
import { and, createId, eq } from "@homarr/db";
|
import { and, createId, eq } from "@homarr/db";
|
||||||
import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite";
|
import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite";
|
||||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
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 { validation } from "@homarr/validation";
|
||||||
|
|
||||||
import { createTRPCRouter, publicProcedure } from "../trpc";
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
|
import { testConnectionAsync } from "./integration-test-connection";
|
||||||
|
|
||||||
export const integrationRouter = createTRPCRouter({
|
export const integrationRouter = createTRPCRouter({
|
||||||
all: publicProcedure.query(async ({ ctx }) => {
|
all: publicProcedure.query(async ({ ctx }) => {
|
||||||
@@ -60,6 +61,14 @@ export const integrationRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
create: publicProcedure.input(validation.integration.create).mutation(async ({ ctx, input }) => {
|
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();
|
const integrationId = createId();
|
||||||
await ctx.db.insert(integrations).values({
|
await ctx.db.insert(integrations).values({
|
||||||
id: integrationId,
|
id: integrationId,
|
||||||
@@ -68,13 +77,14 @@ export const integrationRouter = createTRPCRouter({
|
|||||||
kind: input.kind,
|
kind: input.kind,
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const secret of input.secrets) {
|
if (input.secrets.length >= 1) {
|
||||||
await ctx.db.insert(integrationSecrets).values({
|
await ctx.db.insert(integrationSecrets).values(
|
||||||
kind: secret.kind,
|
input.secrets.map((secret) => ({
|
||||||
value: encryptSecret(secret.value),
|
kind: secret.kind,
|
||||||
updatedAt: new Date(),
|
value: encryptSecret(secret.value),
|
||||||
integrationId,
|
integrationId,
|
||||||
});
|
})),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
update: publicProcedure.input(validation.integration.update).mutation(async ({ ctx, input }) => {
|
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
|
await ctx.db
|
||||||
.update(integrations)
|
.update(integrations)
|
||||||
.set({
|
.set({
|
||||||
@@ -100,15 +121,14 @@ export const integrationRouter = createTRPCRouter({
|
|||||||
})
|
})
|
||||||
.where(eq(integrations.id, input.id));
|
.where(eq(integrations.id, input.id));
|
||||||
|
|
||||||
const decryptedSecrets = integration.secrets.map((secret) => ({
|
|
||||||
...secret,
|
|
||||||
value: decryptSecret(secret.value),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const changedSecrets = input.secrets.filter(
|
const changedSecrets = input.secrets.filter(
|
||||||
(secret): secret is { kind: IntegrationSecretKind; value: string } =>
|
(secret): secret is { kind: IntegrationSecretKind; value: string } =>
|
||||||
secret.value !== null && // only update secrets that have a value
|
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) {
|
if (changedSecrets.length > 0) {
|
||||||
@@ -118,7 +138,7 @@ export const integrationRouter = createTRPCRouter({
|
|||||||
value: changedSecret.value,
|
value: changedSecret.value,
|
||||||
kind: changedSecret.kind,
|
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);
|
await addSecretAsync(ctx.db, secretInput);
|
||||||
} else {
|
} else {
|
||||||
await updateSecretAsync(ctx.db, secretInput);
|
await updateSecretAsync(ctx.db, secretInput);
|
||||||
@@ -140,71 +160,6 @@ export const integrationRouter = createTRPCRouter({
|
|||||||
|
|
||||||
await ctx.db.delete(integrations).where(eq(integrations.id, input.id));
|
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 {
|
interface UpdateSecretInput {
|
||||||
@@ -217,7 +172,6 @@ const updateSecretAsync = async (db: Database, input: UpdateSecretInput) => {
|
|||||||
.update(integrationSecrets)
|
.update(integrationSecrets)
|
||||||
.set({
|
.set({
|
||||||
value: encryptSecret(input.value),
|
value: encryptSecret(input.value),
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
})
|
||||||
.where(and(eq(integrationSecrets.integrationId, input.integrationId), eq(integrationSecrets.kind, input.kind)));
|
.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({
|
await db.insert(integrationSecrets).values({
|
||||||
kind: input.kind,
|
kind: input.kind,
|
||||||
value: encryptSecret(input.value),
|
value: encryptSecret(input.value),
|
||||||
updatedAt: new Date(),
|
|
||||||
integrationId: input.integrationId,
|
integrationId: input.integrationId,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -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]!;
|
||||||
|
};
|
||||||
@@ -7,12 +7,14 @@ import { createId } from "@homarr/db";
|
|||||||
import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite";
|
import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite";
|
||||||
import { createDb } from "@homarr/db/test";
|
import { createDb } from "@homarr/db/test";
|
||||||
|
|
||||||
import type { RouterInputs } from "../..";
|
import { integrationRouter } from "../../integration/integration-router";
|
||||||
import { integrationRouter } from "../integration";
|
import { expectToBeDefined } from "../helper";
|
||||||
import { expectToBeDefined } from "./helper";
|
|
||||||
|
|
||||||
// Mock the auth module to return an empty session
|
// Mock the auth module to return an empty session
|
||||||
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as 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", () => {
|
describe("all should return all integrations", () => {
|
||||||
it("should return all integrations", async () => {
|
it("should return all integrations", async () => {
|
||||||
@@ -290,199 +292,3 @@ describe("delete should delete an integration", () => {
|
|||||||
expect(dbSecrets.length).toBe(0);
|
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ import { initTRPC, TRPCError } from "@trpc/server";
|
|||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
|
||||||
import type { Session } from "@homarr/auth";
|
import type { Session } from "@homarr/auth";
|
||||||
|
import { FlattenError } from "@homarr/common";
|
||||||
import { db } from "@homarr/db";
|
import { db } from "@homarr/db";
|
||||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||||
import { logger } from "@homarr/log";
|
import { logger } from "@homarr/log";
|
||||||
@@ -52,6 +53,7 @@ const t = initTRPC.context<typeof createTRPCContext>().create({
|
|||||||
data: {
|
data: {
|
||||||
...shape.data,
|
...shape.data,
|
||||||
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
|
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
|
||||||
|
error: error.cause instanceof FlattenError ? error.cause.flatten() : null,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,3 +9,16 @@ export const extractErrorMessage = (error: unknown) => {
|
|||||||
|
|
||||||
return "Unknown error";
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
export const appendPath = (url: URL | string, path: string) => {
|
export const appendPath = (url: URL | string, path: string) => {
|
||||||
const newUrl = new URL(url);
|
const newUrl = new URL(url);
|
||||||
newUrl.pathname += path;
|
newUrl.pathname = removeTrailingSlash(newUrl.pathname) + path;
|
||||||
return newUrl;
|
return newUrl;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const removeTrailingSlash = (path: string) => {
|
||||||
|
return path.at(-1) === "/" ? path.substring(0, path.length - 1) : path;
|
||||||
|
};
|
||||||
|
|||||||
@@ -141,7 +141,9 @@ export const integrationSecrets = mysqlTable(
|
|||||||
{
|
{
|
||||||
kind: varchar("kind", { length: 16 }).$type<IntegrationSecretKind>().notNull(),
|
kind: varchar("kind", { length: 16 }).$type<IntegrationSecretKind>().notNull(),
|
||||||
value: text("value").$type<`${string}.${string}`>().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 })
|
integrationId: varchar("integration_id", { length: 64 })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => integrations.id, { onDelete: "cascade" }),
|
.references(() => integrations.id, { onDelete: "cascade" }),
|
||||||
|
|||||||
@@ -144,7 +144,9 @@ export const integrationSecrets = sqliteTable(
|
|||||||
{
|
{
|
||||||
kind: text("kind").$type<IntegrationSecretKind>().notNull(),
|
kind: text("kind").$type<IntegrationSecretKind>().notNull(),
|
||||||
value: text("value").$type<`${string}.${string}`>().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")
|
integrationId: text("integration_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => integrations.id, { onDelete: "cascade" }),
|
.references(() => integrations.id, { onDelete: "cascade" }),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.ts",
|
".": "./index.ts",
|
||||||
|
"./client": "./src/client.ts",
|
||||||
"./types": "./src/types.ts"
|
"./types": "./src/types.ts"
|
||||||
},
|
},
|
||||||
"typesVersions": {
|
"typesVersions": {
|
||||||
@@ -25,7 +26,8 @@
|
|||||||
"@homarr/definitions": "workspace:^0.1.0",
|
"@homarr/definitions": "workspace:^0.1.0",
|
||||||
"@homarr/log": "workspace:^0.1.0",
|
"@homarr/log": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "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": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
|
|||||||
16
packages/integrations/src/base/creator.ts
Normal file
16
packages/integrations/src/base/creator.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,16 +1,25 @@
|
|||||||
|
import { extractErrorMessage } from "@homarr/common";
|
||||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
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";
|
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 {
|
export abstract class Integration {
|
||||||
constructor(
|
constructor(protected integration: IntegrationInput) {}
|
||||||
protected integration: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
decryptedSecrets: IntegrationSecret[];
|
|
||||||
},
|
|
||||||
) {}
|
|
||||||
|
|
||||||
protected getSecretValue(kind: IntegrationSecretKind) {
|
protected getSecretValue(kind: IntegrationSecretKind) {
|
||||||
const secret = this.integration.decryptedSecrets.find((secret) => secret.kind === kind);
|
const secret = this.integration.decryptedSecrets.find((secret) => secret.kind === kind);
|
||||||
@@ -19,4 +28,87 @@ export abstract class Integration {
|
|||||||
}
|
}
|
||||||
return secret.value;
|
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");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
26
packages/integrations/src/base/test-connection-error.ts
Normal file
26
packages/integrations/src/base/test-connection-error.ts
Normal 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;
|
||||||
|
};
|
||||||
1
packages/integrations/src/client.ts
Normal file
1
packages/integrations/src/client.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { convertIntegrationTestConnectionError } from "./base/test-connection-error";
|
||||||
@@ -5,13 +5,9 @@ import { Integration } from "../base/integration";
|
|||||||
import { entityStateSchema } from "./homeassistant-types";
|
import { entityStateSchema } from "./homeassistant-types";
|
||||||
|
|
||||||
export class HomeAssistantIntegration extends Integration {
|
export class HomeAssistantIntegration extends Integration {
|
||||||
async getEntityStateAsync(entityId: string) {
|
public async getEntityStateAsync(entityId: string) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(appendPath(this.integration.url, `/states/${entityId}`), {
|
const response = await this.getAsync(`/api/states/${entityId}`);
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const body = (await response.json()) as unknown;
|
const body = (await response.json()) as unknown;
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
logger.warn(`Response did not indicate success`);
|
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 {
|
try {
|
||||||
const response = await fetch(appendPath(this.integration.url, "/services/automation/trigger"), {
|
const response = await this.postAsync("/api/services/automation/trigger", {
|
||||||
headers: {
|
entity_id: entityId,
|
||||||
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
entity_id: entityId,
|
|
||||||
}),
|
|
||||||
method: "POST",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.ok;
|
return response.ok;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(`Failed to fetch from '${this.integration.url}': ${err as string}`);
|
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.
|
* @param entityId - The ID of the entity to toggle.
|
||||||
* @returns A boolean indicating whether the toggle action was successful.
|
* @returns A boolean indicating whether the toggle action was successful.
|
||||||
*/
|
*/
|
||||||
async triggerToggleAsync(entityId: string) {
|
public async triggerToggleAsync(entityId: string) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(appendPath(this.integration.url, "/services/homeassistant/toggle"), {
|
const response = await this.postAsync("/api/services/homeassistant/toggle", {
|
||||||
headers: {
|
entity_id: entityId,
|
||||||
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
entity_id: entityId,
|
|
||||||
}),
|
|
||||||
method: "POST",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.ok;
|
return response.ok;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(`Failed to fetch from '${this.integration.url}': ${err as string}`);
|
logger.error(`Failed to fetch from '${this.integration.url}': ${err as string}`);
|
||||||
return false;
|
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")}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,7 @@
|
|||||||
|
// General integrations
|
||||||
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
|
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
|
||||||
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
|
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
export { IntegrationTestConnectionError } from "./base/test-connection-error";
|
||||||
|
export { integrationCreatorByKind } from "./base/creator";
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Integration } from "../base/integration";
|
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 { DnsHoleSummaryIntegration } from "../interfaces/dns-hole-summary/dns-hole-summary-integration";
|
||||||
import type { DnsHoleSummary } from "../interfaces/dns-hole-summary/dns-hole-summary-types";
|
import type { DnsHoleSummary } from "../interfaces/dns-hole-summary/dns-hole-summary-types";
|
||||||
import { summaryResponseSchema } from "./pi-hole-types";
|
import { summaryResponseSchema } from "./pi-hole-types";
|
||||||
|
|
||||||
export class PiHoleIntegration extends Integration implements DnsHoleSummaryIntegration {
|
export class PiHoleIntegration extends Integration implements DnsHoleSummaryIntegration {
|
||||||
async getSummaryAsync(): Promise<DnsHoleSummary> {
|
public async getSummaryAsync(): Promise<DnsHoleSummary> {
|
||||||
const apiKey = super.getSecretValue("apiKey");
|
const apiKey = super.getSecretValue("apiKey");
|
||||||
const response = await fetch(`${this.integration.url}/admin/api.php?summaryRaw&auth=${apiKey}`);
|
const response = await fetch(`${this.integration.url}/admin/api.php?summaryRaw&auth=${apiKey}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -28,4 +29,24 @@ export class PiHoleIntegration extends Integration implements DnsHoleSummaryInte
|
|||||||
dnsQueriesToday: result.data.dns_queries_today,
|
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");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
249
packages/integrations/test/base.spec.ts
Normal file
249
packages/integrations/test/base.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
81
packages/integrations/test/home-assistant.spec.ts
Normal file
81
packages/integrations/test/home-assistant.spec.ts
Normal 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)}`,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -25,6 +25,36 @@ describe("Pi-hole integration", () => {
|
|||||||
// Cleanup
|
// Cleanup
|
||||||
await piholeContainer.stop();
|
await piholeContainer.stop();
|
||||||
}, 20_000); // Timeout of 20 seconds
|
}, 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) => {
|
const createPiHoleContainer = (password: string) => {
|
||||||
|
|||||||
BIN
packages/integrations/test/volumes/home-assistant-config.zip
Normal file
BIN
packages/integrations/test/volumes/home-assistant-config.zip
Normal file
Binary file not shown.
@@ -389,7 +389,10 @@ export default {
|
|||||||
create: "New integration",
|
create: "New integration",
|
||||||
},
|
},
|
||||||
testConnection: {
|
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",
|
alertNotice: "The Save button is enabled once a successful connection is established",
|
||||||
notification: {
|
notification: {
|
||||||
success: {
|
success: {
|
||||||
@@ -400,7 +403,7 @@ export default {
|
|||||||
title: "Invalid URL",
|
title: "Invalid URL",
|
||||||
message: "The URL is invalid",
|
message: "The URL is invalid",
|
||||||
},
|
},
|
||||||
notAllSecretsProvided: {
|
secretNotDefined: {
|
||||||
title: "Missing credentials",
|
title: "Missing credentials",
|
||||||
message: "Not all credentials were provided",
|
message: "Not all credentials were provided",
|
||||||
},
|
},
|
||||||
@@ -412,6 +415,50 @@ export default {
|
|||||||
title: "Connection failed",
|
title: "Connection failed",
|
||||||
message: "The connection could not be established",
|
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: {
|
secrets: {
|
||||||
|
|||||||
31
pnpm-lock.yaml
generated
31
pnpm-lock.yaml
generated
@@ -758,6 +758,9 @@ importers:
|
|||||||
'@homarr/log':
|
'@homarr/log':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../log
|
version: link:../log
|
||||||
|
'@homarr/translation':
|
||||||
|
specifier: workspace:^0.1.0
|
||||||
|
version: link:../translation
|
||||||
'@homarr/validation':
|
'@homarr/validation':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../validation
|
version: link:../validation
|
||||||
@@ -3171,20 +3174,20 @@ packages:
|
|||||||
balanced-match@1.0.2:
|
balanced-match@1.0.2:
|
||||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||||
|
|
||||||
bare-events@2.3.1:
|
bare-events@2.4.2:
|
||||||
resolution: {integrity: sha512-sJnSOTVESURZ61XgEleqmP255T6zTYwHPwE4r6SssIh0U9/uDvfpdoJYpVUerJJZH2fueO+CdT8ZT+OC/7aZDA==}
|
resolution: {integrity: sha512-qMKFd2qG/36aA4GwvKq8MxnPgCQAmBWmSyLWsJcbn8v03wvIPQ/hG1Ms8bPzndZxMDoHpxez5VOS+gC9Yi24/Q==}
|
||||||
|
|
||||||
bare-fs@2.3.1:
|
bare-fs@2.3.1:
|
||||||
resolution: {integrity: sha512-W/Hfxc/6VehXlsgFtbB5B4xFcsCl+pAh30cYhoFyXErf6oGrwjh8SwiPAdHgpmWonKuYpZgGywN0SXt7dgsADA==}
|
resolution: {integrity: sha512-W/Hfxc/6VehXlsgFtbB5B4xFcsCl+pAh30cYhoFyXErf6oGrwjh8SwiPAdHgpmWonKuYpZgGywN0SXt7dgsADA==}
|
||||||
|
|
||||||
bare-os@2.3.0:
|
bare-os@2.4.0:
|
||||||
resolution: {integrity: sha512-oPb8oMM1xZbhRQBngTgpcQ5gXw6kjOaRsSWsIeNyRxGed2w/ARyP7ScBYpWR1qfX2E5rS3gBw6OWcSQo+s+kUg==}
|
resolution: {integrity: sha512-v8DTT08AS/G0F9xrhyLtepoo9EJBJ85FRSMbu1pQUlAf6A8T0tEEQGMVObWeqpjhSPXsE0VGlluFBJu2fdoTNg==}
|
||||||
|
|
||||||
bare-path@2.1.3:
|
bare-path@2.1.3:
|
||||||
resolution: {integrity: sha512-lh/eITfU8hrj9Ru5quUp0Io1kJWIk1bTjzo7JH1P5dWmQ2EL4hFUlfI8FonAhSlgIfhn63p84CDY/x+PisgcXA==}
|
resolution: {integrity: sha512-lh/eITfU8hrj9Ru5quUp0Io1kJWIk1bTjzo7JH1P5dWmQ2EL4hFUlfI8FonAhSlgIfhn63p84CDY/x+PisgcXA==}
|
||||||
|
|
||||||
bare-stream@2.1.2:
|
bare-stream@2.1.3:
|
||||||
resolution: {integrity: sha512-az/7TFOh4Gk9Tqs1/xMFq5FuFoeZ9hZ3orsM2x69u8NXVUDXZnpdhG8mZY/Pv6DF954MGn+iIt4rFrG34eQsvg==}
|
resolution: {integrity: sha512-tiDAH9H/kP+tvNO5sczyn9ZAA7utrSMobyDchsnyyXBuUe2FSQWbxhtuHB8jwpHYYevVo2UJpcmvvjrbHboUUQ==}
|
||||||
|
|
||||||
base64-js@1.5.1:
|
base64-js@1.5.1:
|
||||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||||
@@ -6794,7 +6797,7 @@ snapshots:
|
|||||||
|
|
||||||
'@babel/highlight@7.23.4':
|
'@babel/highlight@7.23.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/helper-validator-identifier': 7.22.20
|
'@babel/helper-validator-identifier': 7.24.6
|
||||||
chalk: 2.4.2
|
chalk: 2.4.2
|
||||||
js-tokens: 4.0.0
|
js-tokens: 4.0.0
|
||||||
|
|
||||||
@@ -8515,25 +8518,25 @@ snapshots:
|
|||||||
|
|
||||||
balanced-match@1.0.2: {}
|
balanced-match@1.0.2: {}
|
||||||
|
|
||||||
bare-events@2.3.1:
|
bare-events@2.4.2:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
bare-fs@2.3.1:
|
bare-fs@2.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
bare-events: 2.3.1
|
bare-events: 2.4.2
|
||||||
bare-path: 2.1.3
|
bare-path: 2.1.3
|
||||||
bare-stream: 2.1.2
|
bare-stream: 2.1.3
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
bare-os@2.3.0:
|
bare-os@2.4.0:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
bare-path@2.1.3:
|
bare-path@2.1.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
bare-os: 2.3.0
|
bare-os: 2.4.0
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
bare-stream@2.1.2:
|
bare-stream@2.1.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
streamx: 2.18.0
|
streamx: 2.18.0
|
||||||
optional: true
|
optional: true
|
||||||
@@ -11540,7 +11543,7 @@ snapshots:
|
|||||||
queue-tick: 1.0.1
|
queue-tick: 1.0.1
|
||||||
text-decoder: 1.1.0
|
text-decoder: 1.1.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
bare-events: 2.3.1
|
bare-events: 2.4.2
|
||||||
|
|
||||||
string-width@4.2.3:
|
string-width@4.2.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
Reference in New Issue
Block a user