feat(integration): improve integration test connection (#3005)
This commit is contained in:
@@ -4,6 +4,9 @@ import { z } from "zod";
|
||||
import { zfd } from "zod-form-data";
|
||||
|
||||
import { addCustomRootCertificateAsync, removeCustomRootCertificateAsync } from "@homarr/certificates/server";
|
||||
import { and, eq } from "@homarr/db";
|
||||
import { trustedCertificateHostnames } from "@homarr/db/schema";
|
||||
import { logger } from "@homarr/log";
|
||||
import { certificateValidFileNameSchema, superRefineCertificateFile } from "@homarr/validation/certificates";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
|
||||
@@ -20,8 +23,13 @@ export const certificateRouter = createTRPCRouter({
|
||||
const content = await input.file.text();
|
||||
|
||||
// Validate the certificate
|
||||
let x509Certificate: X509Certificate;
|
||||
try {
|
||||
new X509Certificate(content);
|
||||
x509Certificate = new X509Certificate(content);
|
||||
logger.info("Adding trusted certificate", {
|
||||
subject: x509Certificate.subject,
|
||||
issuer: x509Certificate.issuer,
|
||||
});
|
||||
} catch {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
@@ -30,11 +38,89 @@ export const certificateRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
await addCustomRootCertificateAsync(input.file.name, content);
|
||||
|
||||
logger.info("Added trusted certificate", {
|
||||
subject: x509Certificate.subject,
|
||||
issuer: x509Certificate.issuer,
|
||||
});
|
||||
}),
|
||||
trustHostnameMismatch: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(z.object({ hostname: z.string(), certificate: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Validate the certificate
|
||||
let x509Certificate: X509Certificate;
|
||||
try {
|
||||
x509Certificate = new X509Certificate(input.certificate);
|
||||
logger.info("Adding trusted hostname", {
|
||||
subject: x509Certificate.subject,
|
||||
issuer: x509Certificate.issuer,
|
||||
thumbprint: x509Certificate.fingerprint256,
|
||||
hostname: input.hostname,
|
||||
});
|
||||
} catch {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Invalid certificate",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.insert(trustedCertificateHostnames).values({
|
||||
hostname: input.hostname,
|
||||
thumbprint: x509Certificate.fingerprint256,
|
||||
certificate: input.certificate,
|
||||
});
|
||||
|
||||
logger.info("Added trusted hostname", {
|
||||
subject: x509Certificate.subject,
|
||||
issuer: x509Certificate.issuer,
|
||||
thumbprint: x509Certificate.fingerprint256,
|
||||
hostname: input.hostname,
|
||||
});
|
||||
}),
|
||||
removeTrustedHostname: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(z.object({ hostname: z.string(), thumbprint: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
logger.info("Removing trusted hostname", {
|
||||
hostname: input.hostname,
|
||||
thumbprint: input.thumbprint,
|
||||
});
|
||||
const dbResult = await ctx.db
|
||||
.delete(trustedCertificateHostnames)
|
||||
.where(
|
||||
and(
|
||||
eq(trustedCertificateHostnames.hostname, input.hostname),
|
||||
eq(trustedCertificateHostnames.thumbprint, input.thumbprint),
|
||||
),
|
||||
);
|
||||
|
||||
logger.info("Removed trusted hostname", {
|
||||
hostname: input.hostname,
|
||||
thumbprint: input.thumbprint,
|
||||
count: dbResult.changes,
|
||||
});
|
||||
}),
|
||||
removeCertificate: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(z.object({ fileName: certificateValidFileNameSchema }))
|
||||
.mutation(async ({ input }) => {
|
||||
await removeCustomRootCertificateAsync(input.fileName);
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
logger.info("Removing trusted certificate", {
|
||||
fileName: input.fileName,
|
||||
});
|
||||
|
||||
const certificate = await removeCustomRootCertificateAsync(input.fileName);
|
||||
if (!certificate) return;
|
||||
|
||||
// Delete all trusted hostnames for this certificate
|
||||
await ctx.db
|
||||
.delete(trustedCertificateHostnames)
|
||||
.where(eq(trustedCertificateHostnames.thumbprint, certificate.fingerprint256));
|
||||
|
||||
logger.info("Removed trusted certificate", {
|
||||
fileName: input.fileName,
|
||||
subject: certificate.subject,
|
||||
issuer: certificate.issuer,
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
integrationSecretKindObject,
|
||||
} from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { logger } from "@homarr/log";
|
||||
import { byIdSchema } from "@homarr/validation/common";
|
||||
import {
|
||||
integrationCreateSchema,
|
||||
@@ -34,7 +35,8 @@ import {
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "./integration-access";
|
||||
import { testConnectionAsync } from "./integration-test-connection";
|
||||
import { MissingSecretError, testConnectionAsync } from "./integration-test-connection";
|
||||
import { mapTestConnectionError } from "./map-test-connection-error";
|
||||
|
||||
export const integrationRouter = createTRPCRouter({
|
||||
all: publicProcedure.query(async ({ ctx }) => {
|
||||
@@ -185,14 +187,34 @@ export const integrationRouter = createTRPCRouter({
|
||||
.requiresPermission("integration-create")
|
||||
.input(integrationCreateSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await testConnectionAsync({
|
||||
logger.info("Creating integration", {
|
||||
name: input.name,
|
||||
kind: input.kind,
|
||||
url: input.url,
|
||||
});
|
||||
|
||||
const result = await testConnectionAsync({
|
||||
id: "new",
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
kind: input.kind,
|
||||
secrets: input.secrets,
|
||||
}).catch((error) => {
|
||||
if (!(error instanceof MissingSecretError)) throw error;
|
||||
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
logger.error(result.error);
|
||||
return {
|
||||
error: mapTestConnectionError(result.error),
|
||||
};
|
||||
}
|
||||
|
||||
const integrationId = createId();
|
||||
await ctx.db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
@@ -211,6 +233,13 @@ export const integrationRouter = createTRPCRouter({
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("Created integration", {
|
||||
id: integrationId,
|
||||
name: input.name,
|
||||
kind: input.kind,
|
||||
url: input.url,
|
||||
});
|
||||
|
||||
if (
|
||||
input.attemptSearchEngineCreation &&
|
||||
integrationDefs[input.kind].category.flatMap((category) => category).includes("search")
|
||||
@@ -229,6 +258,10 @@ export const integrationRouter = createTRPCRouter({
|
||||
update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
|
||||
|
||||
logger.info("Updating integration", {
|
||||
id: input.id,
|
||||
});
|
||||
|
||||
const integration = await ctx.db.query.integrations.findFirst({
|
||||
where: eq(integrations.id, input.id),
|
||||
with: {
|
||||
@@ -243,7 +276,7 @@ export const integrationRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
await testConnectionAsync(
|
||||
const testResult = await testConnectionAsync(
|
||||
{
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
@@ -252,7 +285,21 @@ export const integrationRouter = createTRPCRouter({
|
||||
secrets: input.secrets,
|
||||
},
|
||||
integration.secrets,
|
||||
);
|
||||
).catch((error) => {
|
||||
if (!(error instanceof MissingSecretError)) throw error;
|
||||
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
|
||||
if (!testResult.success) {
|
||||
logger.error(testResult.error);
|
||||
return {
|
||||
error: mapTestConnectionError(testResult.error),
|
||||
};
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(integrations)
|
||||
@@ -286,6 +333,13 @@ export const integrationRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("Updated integration", {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
kind: integration.kind,
|
||||
url: input.url,
|
||||
});
|
||||
}),
|
||||
delete: protectedProcedure.input(byIdSchema).mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
|
||||
|
||||
@@ -2,7 +2,7 @@ import { decryptSecret } from "@homarr/common/server";
|
||||
import type { Integration } from "@homarr/db/schema";
|
||||
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
|
||||
import { getAllSecretKindOptions } from "@homarr/definitions";
|
||||
import { createIntegrationAsync, IntegrationTestConnectionError } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
type FormIntegration = Integration & {
|
||||
@@ -19,6 +19,12 @@ export const testConnectionAsync = async (
|
||||
value: `${string}.${string}`;
|
||||
}[] = [],
|
||||
) => {
|
||||
logger.info("Testing connection", {
|
||||
integrationName: integration.name,
|
||||
integrationKind: integration.kind,
|
||||
integrationUrl: integration.url,
|
||||
});
|
||||
|
||||
const formSecrets = integration.secrets
|
||||
.filter((secret) => secret.value !== null)
|
||||
.map((secret) => ({
|
||||
@@ -72,7 +78,15 @@ export const testConnectionAsync = async (
|
||||
decryptedSecrets,
|
||||
});
|
||||
|
||||
await integrationInstance.testConnectionAsync();
|
||||
const result = await integrationInstance.testConnectionAsync();
|
||||
if (result.success) {
|
||||
logger.info("Tested connection successfully", {
|
||||
integrationName: integration.name,
|
||||
integrationKind: integration.kind,
|
||||
integrationUrl: integration.url,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
interface SourcedIntegrationSecret {
|
||||
@@ -87,7 +101,7 @@ const getSecretKindOption = (kind: IntegrationKind, sourcedSecrets: SourcedInteg
|
||||
);
|
||||
|
||||
if (matchingSecretKindOptions.length === 0) {
|
||||
throw new IntegrationTestConnectionError("secretNotDefined");
|
||||
throw new MissingSecretError();
|
||||
}
|
||||
|
||||
if (matchingSecretKindOptions.length === 1) {
|
||||
@@ -122,3 +136,9 @@ const getSecretKindOption = (kind: IntegrationKind, sourcedSecrets: SourcedInteg
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return matchingSecretKindOptions[0]!;
|
||||
};
|
||||
|
||||
export class MissingSecretError extends Error {
|
||||
constructor() {
|
||||
super("No secret defined for this integration");
|
||||
}
|
||||
}
|
||||
|
||||
141
packages/api/src/router/integration/map-test-connection-error.ts
Normal file
141
packages/api/src/router/integration/map-test-connection-error.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { X509Certificate } from "node:crypto";
|
||||
|
||||
import type { RequestErrorCode } from "@homarr/common/server";
|
||||
import type {
|
||||
AnyTestConnectionError,
|
||||
TestConnectionErrorDataOfType,
|
||||
TestConnectionErrorType,
|
||||
} from "@homarr/integrations/test-connection";
|
||||
|
||||
export interface MappedError {
|
||||
name: string;
|
||||
message: string;
|
||||
metadata: { key: string; value: string | number | boolean }[];
|
||||
cause?: MappedError;
|
||||
}
|
||||
|
||||
const ignoredErrorProperties = ["name", "message", "cause", "stack"];
|
||||
const mapError = (error: Error): MappedError => {
|
||||
const metadata = Object.entries(error)
|
||||
.filter(([key]) => !ignoredErrorProperties.includes(key))
|
||||
.map(([key, value]) => {
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
return { key, value };
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((value) => value !== null);
|
||||
return {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
metadata,
|
||||
cause: error.cause && error.cause instanceof Error ? mapError(error.cause) : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export interface MappedCertificate {
|
||||
isSelfSigned: boolean;
|
||||
issuer: string;
|
||||
subject: string;
|
||||
serialNumber: string;
|
||||
validFrom: Date;
|
||||
validTo: Date;
|
||||
fingerprint: string;
|
||||
pem: string;
|
||||
}
|
||||
|
||||
const mapCertificate = (certificate: X509Certificate, code: RequestErrorCode): MappedCertificate => ({
|
||||
isSelfSigned: certificate.ca || code === "DEPTH_ZERO_SELF_SIGNED_CERT",
|
||||
issuer: certificate.issuer,
|
||||
subject: certificate.subject,
|
||||
serialNumber: certificate.serialNumber,
|
||||
validFrom: certificate.validFromDate,
|
||||
validTo: certificate.validToDate,
|
||||
fingerprint: certificate.fingerprint256,
|
||||
pem: certificate.toString(),
|
||||
});
|
||||
|
||||
type MappedData<TType extends TestConnectionErrorType> = TType extends "unknown" | "parse"
|
||||
? undefined
|
||||
: TType extends "certificate"
|
||||
? {
|
||||
type: TestConnectionErrorDataOfType<TType>["requestError"]["type"];
|
||||
reason: TestConnectionErrorDataOfType<TType>["requestError"]["reason"];
|
||||
certificate: MappedCertificate;
|
||||
}
|
||||
: TType extends "request"
|
||||
? {
|
||||
type: TestConnectionErrorDataOfType<TType>["requestError"]["type"];
|
||||
reason: TestConnectionErrorDataOfType<TType>["requestError"]["reason"];
|
||||
}
|
||||
: TType extends "authorization"
|
||||
? {
|
||||
statusCode: TestConnectionErrorDataOfType<TType>["statusCode"];
|
||||
reason: TestConnectionErrorDataOfType<TType>["reason"];
|
||||
}
|
||||
: TType extends "statusCode"
|
||||
? {
|
||||
statusCode: TestConnectionErrorDataOfType<TType>["statusCode"];
|
||||
reason: TestConnectionErrorDataOfType<TType>["reason"];
|
||||
url: TestConnectionErrorDataOfType<TType>["url"];
|
||||
}
|
||||
: never;
|
||||
|
||||
type AnyMappedData = {
|
||||
[TType in TestConnectionErrorType]: MappedData<TType>;
|
||||
}[TestConnectionErrorType];
|
||||
|
||||
const mapData = (error: AnyTestConnectionError): AnyMappedData => {
|
||||
if (error.type === "unknown") return undefined;
|
||||
if (error.type === "parse") return undefined;
|
||||
if (error.type === "certificate") {
|
||||
return {
|
||||
type: error.data.requestError.type,
|
||||
reason: error.data.requestError.reason,
|
||||
certificate: mapCertificate(error.data.certificate, error.data.requestError.code),
|
||||
};
|
||||
}
|
||||
if (error.type === "request") {
|
||||
return {
|
||||
type: error.data.requestError.type,
|
||||
reason: error.data.requestError.reason,
|
||||
};
|
||||
}
|
||||
if (error.type === "authorization") {
|
||||
return {
|
||||
statusCode: error.data.statusCode,
|
||||
reason: error.data.reason,
|
||||
};
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (error.type === "statusCode") {
|
||||
return {
|
||||
statusCode: error.data.statusCode,
|
||||
reason: error.data.reason,
|
||||
url: error.data.url,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported error type: ${(error as AnyTestConnectionError).type}`);
|
||||
};
|
||||
|
||||
interface MappedTestConnectionError<TType extends TestConnectionErrorType> {
|
||||
type: TType;
|
||||
name: string;
|
||||
message: string;
|
||||
data: MappedData<TType>;
|
||||
cause?: MappedError;
|
||||
}
|
||||
export type AnyMappedTestConnectionError = {
|
||||
[TType in TestConnectionErrorType]: MappedTestConnectionError<TType>;
|
||||
}[TestConnectionErrorType];
|
||||
|
||||
export const mapTestConnectionError = (error: AnyTestConnectionError) => {
|
||||
return {
|
||||
type: error.type,
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
data: mapData(error),
|
||||
cause: error.cause ? mapError(error.cause) : undefined,
|
||||
} as AnyMappedTestConnectionError;
|
||||
};
|
||||
@@ -25,7 +25,7 @@ const defaultSessionWithPermissions = (permissions: GroupPermissionKey[] = []) =
|
||||
// Mock the auth module to return an empty session
|
||||
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
|
||||
vi.mock("../../integration/integration-test-connection", () => ({
|
||||
testConnectionAsync: async () => await Promise.resolve(undefined),
|
||||
testConnectionAsync: async () => await Promise.resolve({ success: true }),
|
||||
}));
|
||||
|
||||
describe("all should return all integrations", () => {
|
||||
|
||||
@@ -22,7 +22,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
||||
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||
factorySpy.mockReturnValue(
|
||||
Promise.resolve({
|
||||
testConnectionAsync: async () => await Promise.resolve(),
|
||||
testConnectionAsync: async () => await Promise.resolve({ success: true }),
|
||||
} as homarrIntegrations.PiHoleIntegrationV6),
|
||||
);
|
||||
optionsSpy.mockReturnValue([["apiKey"]]);
|
||||
@@ -64,7 +64,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
||||
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||
factorySpy.mockReturnValue(
|
||||
Promise.resolve({
|
||||
testConnectionAsync: async () => await Promise.resolve(),
|
||||
testConnectionAsync: async () => await Promise.resolve({ success: true }),
|
||||
} as homarrIntegrations.PiHoleIntegrationV6),
|
||||
);
|
||||
optionsSpy.mockReturnValue([["apiKey"]]);
|
||||
@@ -113,7 +113,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
||||
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||
factorySpy.mockReturnValue(
|
||||
Promise.resolve({
|
||||
testConnectionAsync: async () => await Promise.resolve(),
|
||||
testConnectionAsync: async () => await Promise.resolve({ success: true }),
|
||||
} as homarrIntegrations.PiHoleIntegrationV6),
|
||||
);
|
||||
optionsSpy.mockReturnValue([["apiKey"]]);
|
||||
@@ -162,7 +162,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
||||
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||
factorySpy.mockReturnValue(
|
||||
Promise.resolve({
|
||||
testConnectionAsync: async () => await Promise.resolve(),
|
||||
testConnectionAsync: async () => await Promise.resolve({ success: true }),
|
||||
} as homarrIntegrations.PiHoleIntegrationV6),
|
||||
);
|
||||
optionsSpy.mockReturnValue([["username", "password"], ["apiKey"]]);
|
||||
@@ -215,7 +215,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
||||
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||
factorySpy.mockReturnValue(
|
||||
Promise.resolve({
|
||||
testConnectionAsync: async () => await Promise.resolve(),
|
||||
testConnectionAsync: async () => await Promise.resolve({ success: true }),
|
||||
} as homarrIntegrations.PiHoleIntegrationV6),
|
||||
);
|
||||
optionsSpy.mockReturnValue([["username", "password"], ["apiKey"]]);
|
||||
|
||||
Reference in New Issue
Block a user