diff --git a/packages/integrations/src/unifi-controller/test/unifi-controller-integration.spec.ts b/packages/integrations/src/unifi-controller/test/unifi-controller-integration.spec.ts new file mode 100644 index 000000000..b2a83c7b0 --- /dev/null +++ b/packages/integrations/src/unifi-controller/test/unifi-controller-integration.spec.ts @@ -0,0 +1,114 @@ +import { describe, expect, test, vi } from "vitest"; + +vi.hoisted(() => { + process.env.SKIP_ENV_VALIDATION = "true"; + process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1"; + process.env.ENABLE_DNS_CACHING = "false"; +}); + +const controllerCtor = vi.fn(); + +vi.mock("@homarr/redis", () => ({ + createGetSetChannel: () => ({ + getAsync: vi.fn().mockResolvedValue(null), + setAsync: vi.fn().mockResolvedValue(undefined), + removeAsync: vi.fn().mockResolvedValue(undefined), + }), +})); + +vi.mock("@homarr/core/infrastructure/logs", () => ({ + createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }), + ErrorWithMetadata: class extends Error {}, +})); + +vi.mock("@homarr/core/infrastructure/http", () => ({ + createCustomCheckServerIdentity: () => (() => undefined) as never, + fetchWithTrustedCertificatesAsync: vi.fn(), + createAxiosCertificateInstanceAsync: vi.fn().mockResolvedValue({}), + createCertificateAgentAsync: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@homarr/core/infrastructure/certificates", () => ({ + getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]), + getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]), +})); + +vi.mock("axios", () => ({ + default: { + create: vi.fn().mockReturnValue({}), + }, +})); + +vi.mock("http-cookie-agent/http", () => ({ + HttpCookieAgent: class {}, + HttpsCookieAgent: class {}, +})); + +vi.mock("@homarr/node-unifi", () => { + class FakeController { + public options: Record; + constructor(options: Record) { + this.options = options; + controllerCtor(options); + } + public async login(): Promise { + return true; + } + public async getSitesStats(): Promise { + return []; + } + } + return { default: { Controller: FakeController } }; +}); + +import { UnifiControllerIntegration } from "../unifi-controller-integration"; + +const createIntegration = (url: string) => + new UnifiControllerIntegration({ + id: "test-unifi", + name: "Test Unifi", + url, + externalUrl: null, + decryptedSecrets: [ + { kind: "username", value: "admin" }, + { kind: "password", value: "secret" }, + ], + }); + +describe("UnifiControllerIntegration port resolution", () => { + test("http://192.168.1.1 falls back to the 8443 default instead of 80", async () => { + controllerCtor.mockClear(); + await createIntegration("http://192.168.1.1").getNetworkSummaryAsync(); + + expect(controllerCtor).toHaveBeenCalledWith( + expect.objectContaining({ host: "192.168.1.1", port: 8443 }), + ); + }); + + test("https://controller.lan:8443 keeps the user-specified port", async () => { + controllerCtor.mockClear(); + await createIntegration("https://controller.lan:8443").getNetworkSummaryAsync(); + + expect(controllerCtor).toHaveBeenCalledWith( + expect.objectContaining({ host: "controller.lan", port: 8443 }), + ); + }); + + test("https://192.168.1.1:8443 keeps the user-specified port", async () => { + controllerCtor.mockClear(); + await createIntegration("https://192.168.1.1:8443").getNetworkSummaryAsync(); + + expect(controllerCtor).toHaveBeenCalledWith( + expect.objectContaining({ host: "192.168.1.1", port: 8443 }), + ); + }); + + test("an unusual port like 10443 is honored verbatim", async () => { + controllerCtor.mockClear(); + await createIntegration("https://192.168.1.1:10443").getNetworkSummaryAsync(); + + expect(controllerCtor).toHaveBeenCalledWith( + expect.objectContaining({ host: "192.168.1.1", port: 10443 }), + ); + }); +}); diff --git a/packages/integrations/src/unifi-controller/unifi-controller-integration.ts b/packages/integrations/src/unifi-controller/unifi-controller-integration.ts index bee66c956..4071fb302 100644 --- a/packages/integrations/src/unifi-controller/unifi-controller-integration.ts +++ b/packages/integrations/src/unifi-controller/unifi-controller-integration.ts @@ -2,7 +2,6 @@ import type tls from "node:tls"; import axios from "axios"; import { HttpCookieAgent, HttpsCookieAgent } from "http-cookie-agent/http"; -import { getPortFromUrl } from "@homarr/common"; import { getAllTrustedCertificatesAsync, getTrustedCertificateHostnamesAsync, @@ -62,6 +61,10 @@ export class UnifiControllerIntegration extends Integration implements NetworkCo checkServerIdentity: typeof tls.checkServerIdentity; }) { const url = new URL(this.integration.url); + // ponytail: node-unifi hardcodes https:// in its base URL and never serves HTTP. + // Respect the user's explicit port; fall back to 8443 (the integration's defaultPort) + // so a bare "http://192.168.1.1" maps to https://192.168.1.1:8443 instead of 80. + const port = url.port ? Number(url.port) : 8443; const certificateOptions = options ?? { ca: await getAllTrustedCertificatesAsync(), checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()), @@ -69,7 +72,7 @@ export class UnifiControllerIntegration extends Integration implements NetworkCo const client = new Unifi.Controller({ host: url.hostname, - port: getPortFromUrl(url), + port, username: this.getSecretValue("username"), password: this.getSecretValue("password"), createAxiosInstance({ cookies }) {