diff --git a/packages/api/src/open-api.ts b/packages/api/src/open-api.ts index b3530aa19..f398036b1 100644 --- a/packages/api/src/open-api.ts +++ b/packages/api/src/open-api.ts @@ -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) => { diff --git a/packages/api/src/router/integration/integration-router.ts b/packages/api/src/router/integration/integration-router.ts index 9f6439207..5b540a4ca 100644 --- a/packages/api/src/router/integration/integration-router.ts +++ b/packages/api/src/router/integration/integration-router.ts @@ -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: { diff --git a/packages/api/src/router/integration/integration-test-stored-connection.ts b/packages/api/src/router/integration/integration-test-stored-connection.ts new file mode 100644 index 000000000..0395c0350 --- /dev/null +++ b/packages/api/src/router/integration/integration-test-stored-connection.ts @@ -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 }; + } + }); diff --git a/packages/api/src/router/test/integration/integration-test-stored-connection.spec.ts b/packages/api/src/router/test/integration/integration-test-stored-connection.spec.ts new file mode 100644 index 000000000..22c1faded --- /dev/null +++ b/packages/api/src/router/test/integration/integration-test-stored-connection.spec.ts @@ -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" }); +});