feat: test integration connection (#669)

* feat: add test integration for pi-hole

* refactor: test integration for pi-hole

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

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

* fix: add missing onUpdateFn to mysql integration secrets

* fix: format issues

* feat: add home assistant test connection

* fix: deepsource issues

* test: add system integration tests for test connection

* fix: add before all for pulling home assistant image

* test: add unit tests for handleTestConnectionResponseAsync

* test: add unit test for testConnectionAsync

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

* fix: deepsource issues

* fix: deepsource issue

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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