Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
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<string, unknown>;
constructor(options: Record<string, unknown>) {
this.options = options;
controllerCtor(options);
}
public async login(): Promise<true> {
return true;
}
public async getSitesStats(): Promise<unknown[]> {
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 }));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -62,14 +61,18 @@ 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()),
};

const client = new Unifi.Controller({
host: url.hostname,
port: getPortFromUrl(url),
port,
username: this.getSecretValue("username"),
password: this.getSecretValue("password"),
createAxiosInstance({ cookies }) {
Expand Down