feat(api): test connections using stored integration credentials (#7014)
This commit is contained in:
1 parent
a9820e99a4
commit
55b203ad93
4 files changed
+150
-1
No files matched your search
@@ -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" });
|
||||
});
|
||||
Reference in new issue
Block a user