feat(integration): improve integration test connection (#3005)
This commit is contained in:
1 parent
3daf1c8341
commit
ef9a5e9895
111 files changed
+7168
-976
No files matched your search
@@ -0,0 +1,6 @@
|
||||
export type {
|
||||
TestConnectionError,
|
||||
AnyTestConnectionError,
|
||||
TestConnectionErrorDataOfType,
|
||||
TestConnectionErrorType,
|
||||
} from "./test-connection-error";
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { X509Certificate } from "node:crypto";
|
||||
|
||||
import type { AnyRequestError, ParseError, RequestError } from "@homarr/common/server";
|
||||
|
||||
import { IntegrationRequestError } from "../errors/http/integration-request-error";
|
||||
import { IntegrationResponseError } from "../errors/http/integration-response-error";
|
||||
import type { IntegrationError } from "../errors/integration-error";
|
||||
import { IntegrationUnknownError } from "../errors/integration-unknown-error";
|
||||
import { IntegrationParseError } from "../errors/parse/integration-parse-error";
|
||||
|
||||
export type TestConnectionErrorType = keyof TestConnectionErrorMap;
|
||||
export type AnyTestConnectionError = {
|
||||
[TType in TestConnectionErrorType]: TestConnectionError<TType>;
|
||||
}[TestConnectionErrorType];
|
||||
export type TestConnectionErrorDataOfType<TType extends TestConnectionErrorType> = TestConnectionErrorMap[TType];
|
||||
|
||||
export class TestConnectionError<TType extends TestConnectionErrorType> extends Error {
|
||||
public readonly type: TType;
|
||||
public readonly data: TestConnectionErrorMap[TType];
|
||||
|
||||
private constructor(type: TType, data: TestConnectionErrorMap[TType], options?: { cause: Error }) {
|
||||
super("Unable to connect to the integration", options);
|
||||
this.name = TestConnectionError.name;
|
||||
this.type = type;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
get cause(): Error | undefined {
|
||||
return super.cause as Error | undefined;
|
||||
}
|
||||
|
||||
public toResult() {
|
||||
return {
|
||||
success: false,
|
||||
error: this,
|
||||
} as const;
|
||||
}
|
||||
|
||||
private static Unknown(cause: unknown) {
|
||||
return new TestConnectionError(
|
||||
"unknown",
|
||||
undefined,
|
||||
cause instanceof Error
|
||||
? {
|
||||
cause,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
|
||||
public static UnknownResult(cause: unknown) {
|
||||
return this.Unknown(cause).toResult();
|
||||
}
|
||||
|
||||
private static Certificate(requestError: RequestError<"certificate">, certificate: X509Certificate) {
|
||||
return new TestConnectionError(
|
||||
"certificate",
|
||||
{
|
||||
requestError,
|
||||
certificate,
|
||||
},
|
||||
{
|
||||
cause: requestError,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public static CertificateResult(requestError: RequestError<"certificate">, certificate: X509Certificate) {
|
||||
return this.Certificate(requestError, certificate).toResult();
|
||||
}
|
||||
|
||||
private static Authorization(statusCode: number) {
|
||||
return new TestConnectionError("authorization", {
|
||||
statusCode,
|
||||
reason: statusCode === 403 ? "forbidden" : "unauthorized",
|
||||
});
|
||||
}
|
||||
|
||||
public static UnauthorizedResult(statusCode: number) {
|
||||
return this.Authorization(statusCode).toResult();
|
||||
}
|
||||
|
||||
private static Status(input: { status: number; url: string }) {
|
||||
if (input.status === 401 || input.status === 403) return this.Authorization(input.status);
|
||||
|
||||
// We don't want to leak the query parameters in the error message
|
||||
const urlWithoutQuery = new URL(input.url);
|
||||
urlWithoutQuery.search = "";
|
||||
|
||||
return new TestConnectionError("statusCode", {
|
||||
statusCode: input.status,
|
||||
reason: input.status in statusCodeMap ? statusCodeMap[input.status as keyof typeof statusCodeMap] : "other",
|
||||
url: urlWithoutQuery.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
public static StatusResult(input: { status: number; url: string }) {
|
||||
return this.Status(input).toResult();
|
||||
}
|
||||
|
||||
private static Request(requestError: Exclude<AnyRequestError, RequestError<"certificate">>) {
|
||||
return new TestConnectionError(
|
||||
"request",
|
||||
{ requestError },
|
||||
{
|
||||
cause: requestError,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public static RequestResult(requestError: Exclude<AnyRequestError, RequestError<"certificate">>) {
|
||||
return this.Request(requestError).toResult();
|
||||
}
|
||||
|
||||
private static Parse(cause: ParseError) {
|
||||
return new TestConnectionError("parse", undefined, { cause });
|
||||
}
|
||||
|
||||
public static ParseResult(cause: ParseError) {
|
||||
return this.Parse(cause).toResult();
|
||||
}
|
||||
|
||||
static FromIntegrationError(error: IntegrationError): AnyTestConnectionError {
|
||||
if (error instanceof IntegrationUnknownError) {
|
||||
return this.Unknown(error.cause);
|
||||
}
|
||||
if (error instanceof IntegrationRequestError) {
|
||||
if (error.cause.type === "certificate") {
|
||||
return this.Unknown(new Error("FromIntegrationError can not be used for certificate errors", { cause: error }));
|
||||
}
|
||||
|
||||
return this.Request(error.cause);
|
||||
}
|
||||
if (error instanceof IntegrationResponseError) {
|
||||
return this.Status({
|
||||
status: error.cause.statusCode,
|
||||
url: error.cause.url ?? "?",
|
||||
});
|
||||
}
|
||||
if (error instanceof IntegrationParseError) {
|
||||
return this.Parse(error.cause);
|
||||
}
|
||||
|
||||
return this.Unknown(new Error("FromIntegrationError received unknown IntegrationError", { cause: error }));
|
||||
}
|
||||
}
|
||||
|
||||
const statusCodeMap = {
|
||||
400: "badRequest",
|
||||
404: "notFound",
|
||||
429: "tooManyRequests",
|
||||
500: "internalServerError",
|
||||
503: "serviceUnavailable",
|
||||
504: "gatewayTimeout",
|
||||
} as const;
|
||||
|
||||
interface TestConnectionErrorMap {
|
||||
unknown: undefined;
|
||||
parse: undefined;
|
||||
authorization: {
|
||||
statusCode: number;
|
||||
reason: "unauthorized" | "forbidden";
|
||||
};
|
||||
statusCode: {
|
||||
statusCode: number;
|
||||
reason: (typeof statusCodeMap)[keyof typeof statusCodeMap] | "other";
|
||||
url: string;
|
||||
};
|
||||
certificate: {
|
||||
requestError: RequestError<"certificate">;
|
||||
certificate: X509Certificate;
|
||||
};
|
||||
request: {
|
||||
requestError: Exclude<AnyRequestError, RequestError<"certificate">>;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { X509Certificate } from "node:crypto";
|
||||
import tls from "node:tls";
|
||||
|
||||
import {
|
||||
createCustomCheckServerIdentity,
|
||||
getAllTrustedCertificatesAsync,
|
||||
getTrustedCertificateHostnamesAsync,
|
||||
} from "@homarr/certificates/server";
|
||||
import { getPortFromUrl } from "@homarr/common";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import type { IntegrationRequestErrorOfType } from "../errors/http/integration-request-error";
|
||||
import { IntegrationRequestError } from "../errors/http/integration-request-error";
|
||||
import { IntegrationError } from "../errors/integration-error";
|
||||
import type { AnyTestConnectionError } from "./test-connection-error";
|
||||
import { TestConnectionError } from "./test-connection-error";
|
||||
|
||||
const localLogger = logger.child({
|
||||
module: "TestConnectionService",
|
||||
});
|
||||
|
||||
export type TestingResult =
|
||||
| {
|
||||
success: true;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
error: AnyTestConnectionError;
|
||||
};
|
||||
type AsyncTestingCallback = (input: {
|
||||
ca: string[] | string;
|
||||
checkServerIdentity: typeof tls.checkServerIdentity;
|
||||
}) => Promise<TestingResult>;
|
||||
|
||||
export class TestConnectionService {
|
||||
constructor(private url: URL) {}
|
||||
|
||||
public async handleAsync(testingCallbackAsync: AsyncTestingCallback) {
|
||||
localLogger.debug("Testing connection", {
|
||||
url: this.url.toString(),
|
||||
});
|
||||
|
||||
const testingResult = await testingCallbackAsync({
|
||||
ca: await getAllTrustedCertificatesAsync(),
|
||||
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
|
||||
})
|
||||
.then((result) => {
|
||||
if (result.success) return result;
|
||||
|
||||
const error = result.error;
|
||||
if (error instanceof TestConnectionError) return error.toResult();
|
||||
|
||||
return TestConnectionError.UnknownResult(error);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!(error instanceof IntegrationError)) {
|
||||
return TestConnectionError.UnknownResult(error);
|
||||
}
|
||||
|
||||
if (!(error instanceof IntegrationRequestError)) {
|
||||
return TestConnectionError.FromIntegrationError(error).toResult();
|
||||
}
|
||||
|
||||
if (error.cause.type !== "certificate") {
|
||||
return TestConnectionError.FromIntegrationError(error).toResult();
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error as IntegrationRequestErrorOfType<"certificate">,
|
||||
} as const;
|
||||
});
|
||||
|
||||
if (testingResult.success) {
|
||||
localLogger.debug("Testing connection succeeded", {
|
||||
url: this.url.toString(),
|
||||
});
|
||||
|
||||
return testingResult;
|
||||
}
|
||||
|
||||
localLogger.debug("Testing connection failed", {
|
||||
url: this.url.toString(),
|
||||
error: `${testingResult.error.name}: ${testingResult.error.message}`,
|
||||
});
|
||||
|
||||
if (!(testingResult.error instanceof IntegrationRequestError)) {
|
||||
return testingResult.error.toResult();
|
||||
}
|
||||
|
||||
const certificate = await this.fetchCertificateAsync();
|
||||
if (!certificate) {
|
||||
return TestConnectionError.UnknownResult(new Error("Unable to fetch certificate"));
|
||||
}
|
||||
|
||||
return TestConnectionError.CertificateResult(testingResult.error.cause, certificate);
|
||||
}
|
||||
|
||||
private async fetchCertificateAsync(): Promise<X509Certificate | undefined> {
|
||||
logger.debug("Fetching certificate", {
|
||||
url: this.url.toString(),
|
||||
});
|
||||
|
||||
const url = this.url;
|
||||
const port = getPortFromUrl(url);
|
||||
const socket = await new Promise<tls.TLSSocket>((resolve, reject) => {
|
||||
try {
|
||||
const innerSocket = tls.connect(
|
||||
{
|
||||
host: url.hostname,
|
||||
servername: url.hostname,
|
||||
port,
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
() => {
|
||||
resolve(innerSocket);
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
reject(new Error("Unable to fetch certificate", { cause: error }));
|
||||
}
|
||||
});
|
||||
|
||||
const x509 = socket.getPeerX509Certificate();
|
||||
socket.destroy();
|
||||
|
||||
localLogger.debug("Fetched certificate", {
|
||||
url: this.url.toString(),
|
||||
subject: x509?.subject,
|
||||
issuer: x509?.issuer,
|
||||
});
|
||||
return x509;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user