feat(integration): improve integration test connection (#3005)
This commit is contained in:
@@ -6,18 +6,25 @@ import { PiHoleIntegrationV5 } from "./v5/pi-hole-integration-v5";
|
||||
import { PiHoleIntegrationV6 } from "./v6/pi-hole-integration-v6";
|
||||
|
||||
export const createPiHoleIntegrationAsync = async (input: IntegrationInput) => {
|
||||
const baseUrl = removeTrailingSlash(input.url);
|
||||
const url = new URL(`${baseUrl}/api/info/version`);
|
||||
const response = await fetchWithTrustedCertificatesAsync(url);
|
||||
try {
|
||||
const baseUrl = removeTrailingSlash(input.url);
|
||||
const url = new URL(`${baseUrl}/api/info/version`);
|
||||
const response = await fetchWithTrustedCertificatesAsync(url);
|
||||
|
||||
/**
|
||||
* In pi-hole 5 the api was at /admin/api.php, in pi-hole 6 it was moved to /api
|
||||
* For the /api/info/version endpoint, the response is 404 in pi-hole 5
|
||||
* and 401 in pi-hole 6
|
||||
*/
|
||||
if (response.status === 404) {
|
||||
return new PiHoleIntegrationV5(input);
|
||||
/**
|
||||
* In pi-hole 5 the api was at /admin/api.php, in pi-hole 6 it was moved to /api
|
||||
* For the /api/info/version endpoint, the response is 404 in pi-hole 5
|
||||
* and 401 in pi-hole 6
|
||||
*/
|
||||
if (response.status === 404) {
|
||||
return new PiHoleIntegrationV5(input);
|
||||
}
|
||||
|
||||
return new PiHoleIntegrationV6(input);
|
||||
} catch {
|
||||
// We fall back to v6 if we can't reach the endpoint
|
||||
// This is the case if the integration is not reachable
|
||||
// the error will then be handled in the integration
|
||||
return new PiHoleIntegrationV6(input);
|
||||
}
|
||||
|
||||
return new PiHoleIntegrationV6(input);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
|
||||
import type { IntegrationTestingInput } from "../../base/integration";
|
||||
import { Integration } from "../../base/integration";
|
||||
import { IntegrationTestConnectionError } from "../../base/test-connection-error";
|
||||
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../../base/test-connection/test-connection-service";
|
||||
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-schemas-v5";
|
||||
@@ -11,46 +14,35 @@ export class PiHoleIntegrationV5 extends Integration implements DnsHoleSummaryIn
|
||||
const apiKey = super.getSecretValue("apiKey");
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/admin/api.php?summaryRaw", { auth: apiKey }));
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch summary for ${this.integration.name} (${this.integration.id}): ${response.statusText}`,
|
||||
);
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
|
||||
const result = summaryResponseSchema.safeParse(await response.json());
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`Failed to parse summary for ${this.integration.name} (${this.integration.id}), most likely your api key is wrong: ${result.error.message}`,
|
||||
);
|
||||
}
|
||||
const data = await summaryResponseSchema.parseAsync(await response.json());
|
||||
|
||||
return {
|
||||
status: result.data.status,
|
||||
adsBlockedToday: result.data.ads_blocked_today,
|
||||
adsBlockedTodayPercentage: result.data.ads_percentage_today,
|
||||
domainsBeingBlocked: result.data.domains_being_blocked,
|
||||
dnsQueriesToday: result.data.dns_queries_today,
|
||||
status: data.status,
|
||||
adsBlockedToday: data.ads_blocked_today,
|
||||
adsBlockedTodayPercentage: data.ads_percentage_today,
|
||||
domainsBeingBlocked: data.domains_being_blocked,
|
||||
dnsQueriesToday: data.dns_queries_today,
|
||||
};
|
||||
}
|
||||
|
||||
public async testConnectionAsync(): Promise<void> {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const apiKey = super.getSecretValue("apiKey");
|
||||
|
||||
await super.handleTestConnectionResponseAsync({
|
||||
queryFunctionAsync: async () => {
|
||||
return await fetchWithTrustedCertificatesAsync(this.url("/admin/api.php?status", { auth: apiKey }));
|
||||
},
|
||||
handleResponseAsync: async (response) => {
|
||||
try {
|
||||
const result = await response.json();
|
||||
if (typeof result === "object" && result !== null && "status" in result) return;
|
||||
} catch {
|
||||
throw new IntegrationTestConnectionError("invalidJson");
|
||||
}
|
||||
const response = await input.fetchAsync(this.url("/admin/api.php?status", { auth: apiKey }));
|
||||
|
||||
throw new IntegrationTestConnectionError("invalidCredentials");
|
||||
},
|
||||
});
|
||||
if (!response.ok) return TestConnectionError.StatusResult(response);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Pi-hole v5 returned an empty array if the API key is wrong
|
||||
if (typeof data !== "object" || Array.isArray(data)) {
|
||||
return TestConnectionError.UnauthorizedResult(401);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
public async enableAsync(): Promise<void> {
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import type { Response as UndiciResponse } from "undici";
|
||||
import type { fetch as undiciFetch, Response as UndiciResponse } from "undici";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||
import { extractErrorMessage } from "@homarr/common";
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { IntegrationResponseError, ParseError, ResponseError } from "../../base/error";
|
||||
import type { IntegrationInput } from "../../base/integration";
|
||||
import type { IntegrationInput, IntegrationTestingInput } from "../../base/integration";
|
||||
import { Integration } from "../../base/integration";
|
||||
import type { SessionStore } from "../../base/session-store";
|
||||
import { createSessionStore } from "../../base/session-store";
|
||||
import { IntegrationTestConnectionError } from "../../base/test-connection-error";
|
||||
import type { TestingResult } from "../../base/test-connection/test-connection-service";
|
||||
import type { DnsHoleSummaryIntegration } from "../../interfaces/dns-hole-summary/dns-hole-summary-integration";
|
||||
import type { DnsHoleSummary } from "../../types";
|
||||
import { dnsBlockingGetSchema, sessionResponseSchema, statsSummaryGetSchema } from "./pi-hole-schemas-v6";
|
||||
@@ -35,21 +34,17 @@ export class PiHoleIntegrationV6 extends Integration implements DnsHoleSummaryIn
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new IntegrationResponseError(this.integration, response, await response.json());
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
|
||||
const result = dnsBlockingGetSchema.safeParse(await response.json());
|
||||
const result = await dnsBlockingGetSchema.parseAsync(await response.json());
|
||||
|
||||
if (!result.success) {
|
||||
throw new ParseError("DNS blocking status", result.error, await response.json());
|
||||
}
|
||||
|
||||
return result.data;
|
||||
return result;
|
||||
}
|
||||
|
||||
private async getStatsSummaryAsync(): Promise<z.infer<typeof statsSummaryGetSchema>> {
|
||||
const response = await this.withAuthAsync(async (sessionId) => {
|
||||
return await fetchWithTrustedCertificatesAsync(this.url("/api/stats/summary"), {
|
||||
return fetchWithTrustedCertificatesAsync(this.url("/api/stats/summary"), {
|
||||
headers: {
|
||||
sid: sessionId,
|
||||
},
|
||||
@@ -57,17 +52,13 @@ export class PiHoleIntegrationV6 extends Integration implements DnsHoleSummaryIn
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new IntegrationResponseError(this.integration, response, await response.json());
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const result = statsSummaryGetSchema.safeParse(data);
|
||||
const result = await statsSummaryGetSchema.parseAsync(data);
|
||||
|
||||
if (!result.success) {
|
||||
throw new ParseError("stats summary", result.error, data);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
return result;
|
||||
}
|
||||
|
||||
public async getSummaryAsync(): Promise<DnsHoleSummary> {
|
||||
@@ -83,21 +74,10 @@ export class PiHoleIntegrationV6 extends Integration implements DnsHoleSummaryIn
|
||||
};
|
||||
}
|
||||
|
||||
public async testConnectionAsync(): Promise<void> {
|
||||
try {
|
||||
const sessionId = await this.getSessionAsync();
|
||||
await this.clearSessionAsync(sessionId);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ParseError) {
|
||||
throw new IntegrationTestConnectionError("invalidJson");
|
||||
}
|
||||
|
||||
if (error instanceof ResponseError && error.statusCode === 401) {
|
||||
throw new IntegrationTestConnectionError("invalidCredentials");
|
||||
}
|
||||
|
||||
throw new IntegrationTestConnectionError("commonError", extractErrorMessage(error));
|
||||
}
|
||||
protected async testingAsync({ fetchAsync }: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const sessionId = await this.getSessionAsync(fetchAsync);
|
||||
await this.clearSessionAsync(sessionId, fetchAsync);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
public async enableAsync(): Promise<void> {
|
||||
@@ -112,7 +92,7 @@ export class PiHoleIntegrationV6 extends Integration implements DnsHoleSummaryIn
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new IntegrationResponseError(this.integration, response, await response.json());
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +108,7 @@ export class PiHoleIntegrationV6 extends Integration implements DnsHoleSummaryIn
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new IntegrationResponseError(this.integration, response, await response.json());
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,35 +140,39 @@ export class PiHoleIntegrationV6 extends Integration implements DnsHoleSummaryIn
|
||||
* Get a session id from the Pi-hole server
|
||||
* @returns The session id
|
||||
*/
|
||||
private async getSessionAsync(): Promise<string> {
|
||||
private async getSessionAsync(fetchAsync: typeof undiciFetch = fetchWithTrustedCertificatesAsync): Promise<string> {
|
||||
const apiKey = super.getSecretValue("apiKey");
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/auth"), {
|
||||
const response = await fetchAsync(this.url("/api/auth"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password: apiKey }),
|
||||
headers: {
|
||||
"User-Agent": "Homarr",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) throw new ResponseError(response);
|
||||
|
||||
const data = await response.json();
|
||||
const result = sessionResponseSchema.safeParse(data);
|
||||
if (!result.success) {
|
||||
throw new ParseError("session response", result.error, data);
|
||||
}
|
||||
if (!result.data.session.sid) {
|
||||
throw new IntegrationResponseError(this.integration, response, data);
|
||||
const result = await sessionResponseSchema.parseAsync(data);
|
||||
|
||||
if (!result.session.sid) {
|
||||
throw new ResponseError({ status: 401, url: response.url });
|
||||
}
|
||||
|
||||
localLogger.info("Received session id successfully", { integrationId: this.integration.id });
|
||||
|
||||
return result.data.session.sid;
|
||||
return result.session.sid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the session from the Pi-hole server
|
||||
* @param sessionId The session id to remove
|
||||
*/
|
||||
private async clearSessionAsync(sessionId: string) {
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/auth"), {
|
||||
private async clearSessionAsync(
|
||||
sessionId: string,
|
||||
fetchAsync: typeof undiciFetch = fetchWithTrustedCertificatesAsync,
|
||||
) {
|
||||
const response = await fetchAsync(this.url("/api/auth"), {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
sid: sessionId,
|
||||
|
||||
Reference in New Issue
Block a user