feat(api): test connections using stored integration credentials (#7014)

This commit is contained in:
Thomas authored and GitHub committed 2026-10-03 21:51:34 +02:00
1 parent a9820e99a4
commit 55b203ad93
4 files changed
+150 -1

No files matched your search

+5 -1
View File
@@ -5,6 +5,7 @@ import { API_KEY_HEADER_NAME } from "@homarr/auth/api-key";
import { appRouter } from "./router/app";
import { boardRouter } from "./router/board";
import { integrationRequestProcedure } from "./router/integration/integration-request";
import { integrationTestStoredConnectionProcedure } from "./router/integration/integration-test-stored-connection";
import { infoRouter } from "./router/info";
import { inviteRouter } from "./router/invite";
import { serverSettingsRouter } from "./router/serverSettings";
@@ -20,7 +21,10 @@ export const openApiRouter = createTRPCRouter({
inviteRouter,
serverSettingsRouter,
userRouter,
integration: createTRPCRouter({ request: integrationRequestProcedure }),
integration: createTRPCRouter({
request: integrationRequestProcedure,
testConnection: integrationTestStoredConnectionProcedure,
}),
});
export const openApiDocument = (base: string) => {
@@ -44,6 +44,7 @@ import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publ
import { throwIfActionForbiddenAsync } from "./integration-access";
import { integrationRequestProcedure } from "./integration-request";
import { MissingSecretError, testConnectionAsync } from "./integration-test-connection";
import { integrationTestStoredConnectionProcedure } from "./integration-test-stored-connection";
import { mapTestConnectionError } from "./map-test-connection-error";
const logger = createLogger({ module: "integrationRouter" });
@@ -51,6 +52,7 @@ const mediaRequestSearchKinds = getIntegrationKindsByCategory("mediaSearch");
export const integrationRouter = createTRPCRouter({
request: integrationRequestProcedure,
testConnection: integrationTestStoredConnectionProcedure,
getKinds: publicProcedure
.meta({
mcp: {
@@ -0,0 +1,57 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { eq } from "@homarr/db";
import { integrations } from "@homarr/db/schema";
import { byIdSchema } from "@homarr/validation/common";
import { protectedProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "./integration-access";
import { MissingSecretError, testConnectionAsync } from "./integration-test-connection";
export const integrationTestStoredConnectionProcedure = protectedProcedure
.meta({
openapi: {
method: "GET",
path: "/api/integrations/{id}/test-connection",
tags: ["integrations"],
protect: true,
summary: "Test a saved integration using stored credentials",
description:
"Requires full integration access. Runs the native connection check without changing saved configuration. Upstream authentication may establish a session. Success is a connection check, not widget or service acceptance. Failures expose only a safe category, not upstream errors or credentials.",
},
})
.input(byIdSchema)
.output(
z.discriminatedUnion("success", [
z.object({ success: z.literal(true) }),
z.object({
success: z.literal(false),
error: z.enum(["missingSecret", "authorization", "certificate", "request", "statusCode", "parse", "unknown"]),
}),
]),
)
.query(async ({ ctx, input }) => {
const where = eq(integrations.id, input.id);
await throwIfActionForbiddenAsync(ctx, where, "full");
const integration = await ctx.db.query.integrations.findFirst({ where, with: { secrets: true } });
if (!integration) {
throw new TRPCError({ code: "NOT_FOUND", message: "Integration not found" });
}
try {
const result = await testConnectionAsync(
{
...integration,
secrets: integration.secrets.map(({ kind }) => ({ kind, value: null })),
},
integration.secrets,
);
if (result.success) return { success: true as const };
return { success: false as const, error: result.error.type };
} catch (error) {
if (error instanceof MissingSecretError) return { success: false as const, error: "missingSecret" as const };
return { success: false as const, error: "unknown" as const };
}
});
@@ -0,0 +1,86 @@
import { beforeEach, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { encryptSecret } from "@homarr/common/server";
import { integrations, integrationSecrets } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { integrationTestStoredConnectionProcedure } from "../../integration/integration-test-stored-connection";
import { createTRPCRouter } from "../../../trpc";
const { factory, upstreamTest } = vi.hoisted(() => ({ factory: vi.fn(), upstreamTest: vi.fn() }));
vi.mock("@homarr/integrations/factory", () => ({ createIntegrationAsync: factory }));
beforeEach(() => {
factory.mockReset().mockResolvedValue({ testConnectionAsync: upstreamTest });
upstreamTest.mockReset().mockResolvedValue({ success: true });
});
const setup = async (permissions: Session["user"]["permissions"] = ["integration-full-all"]) => {
const db = createDb();
await db.insert(integrations).values({ id: "saved", name: "Sonarr", kind: "sonarr", url: "http://sonarr:8989" });
await db
.insert(integrationSecrets)
.values({ integrationId: "saved", kind: "apiKey", value: encryptSecret("private-key") });
const session: Session = {
user: { id: "owner", permissions, colorScheme: "light" },
expires: new Date().toISOString(),
};
const router = createTRPCRouter({ testConnection: integrationTestStoredConnectionProcedure });
return { db, router, caller: router.createCaller({ db, session, deviceType: undefined }) };
};
test("tests stored credentials without modifying saved configuration", async () => {
const { db, caller } = await setup();
const before = await db.query.integrations.findFirst({ with: { secrets: true } });
expect(await caller.testConnection({ id: "saved" })).toEqual({ success: true });
expect(factory).toHaveBeenCalledWith(
expect.objectContaining({
id: "saved",
decryptedSecrets: [expect.objectContaining({ kind: "apiKey", value: "private-key" })],
}),
);
expect(await db.query.integrations.findFirst({ with: { secrets: true } })).toEqual(before);
});
test("denies use-only access and hides missing integrations before loading secrets", async () => {
const { caller } = await setup(["integration-use-all"]);
await expect(caller.testConnection({ id: "saved" })).rejects.toMatchObject({ code: "NOT_FOUND" });
await expect(caller.testConnection({ id: "missing" })).rejects.toMatchObject({ code: "NOT_FOUND" });
expect(factory).not.toHaveBeenCalled();
});
test("requires authentication", async () => {
const { db, router } = await setup();
const caller = router.createCaller({ db, session: null, deviceType: undefined });
await expect(caller.testConnection({ id: "saved" })).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(factory).not.toHaveBeenCalled();
});
test("returns only safe categories for upstream failures and thrown errors", async () => {
const { caller } = await setup();
upstreamTest.mockResolvedValueOnce({
success: false,
error: {
type: "authorization",
message: "private-key",
data: { token: "private-key" },
cause: new Error("private-key"),
},
});
expect(await caller.testConnection({ id: "saved" })).toEqual({ success: false, error: "authorization" });
factory.mockRejectedValueOnce(new Error("private-key"));
expect(await caller.testConnection({ id: "saved" })).toEqual({ success: false, error: "unknown" });
const { db } = await setup();
await db.delete(integrationSecrets);
const router = createTRPCRouter({ testConnection: integrationTestStoredConnectionProcedure });
const noSecretsCaller = router.createCaller({
db,
session: {
user: { id: "owner", permissions: ["integration-full-all"], colorScheme: "light" },
expires: new Date().toISOString(),
},
deviceType: undefined,
});
expect(await noSecretsCaller.testConnection({ id: "saved" })).toEqual({ success: false, error: "missingSecret" });
});