diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index 66e184a8d..e831b9433 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -54,6 +54,7 @@ import { UmamiIntegration } from "../umami/umami-integration"; import { UptimeKumaIntegration } from "../uptime-kuma/uptime-kuma-integration"; import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration"; import { UnifiOsIntegration } from "../unifi-os/unifi-os-integration"; +import { UnifiSiteManagerIntegration } from "../unifi-site-manager/unifi-site-manager-integration"; import { UnraidIntegration } from "../unraid/unraid-integration"; import { WudIntegration } from "../wud/wud-integration"; import type { Integration, IntegrationInput } from "./integration"; @@ -111,6 +112,7 @@ export const integrationCreators = { nextcloud: NextcloudIntegration, unifiController: UnifiControllerIntegration, unifiOs: UnifiOsIntegration, + unifiSiteManager: UnifiSiteManagerIntegration, opnsense: OPNsenseIntegration, ical: ICalIntegration, ntfy: NTFYIntegration, diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 27b2e0f62..434672e64 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -100,6 +100,7 @@ export { anchorNoteUpdateInputSchema } from "./anchor/anchor-types"; export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items"; export { UnifiOsIntegration } from "./unifi-os/unifi-os-integration"; +export { UnifiSiteManagerIntegration } from "./unifi-site-manager/unifi-site-manager-integration"; // Helpers export { createIntegrationAsync } from "./base/creator"; diff --git a/packages/integrations/src/interfaces/unifi-site-manager/unifi-site-manager-integration.ts b/packages/integrations/src/interfaces/unifi-site-manager/unifi-site-manager-integration.ts new file mode 100644 index 000000000..e98b65776 --- /dev/null +++ b/packages/integrations/src/interfaces/unifi-site-manager/unifi-site-manager-integration.ts @@ -0,0 +1,5 @@ +import type { UnifiSiteManagerOverview } from "../../unifi-site-manager/unifi-site-manager-types"; + +export interface UnifiSiteManagerIntegration { + getSiteOverviewAsync(): Promise; +} diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts index 56a70a6e2..ae75d5159 100644 --- a/packages/integrations/src/types.ts +++ b/packages/integrations/src/types.ts @@ -15,6 +15,8 @@ export * from "./proxmox/proxmox-types"; export * from "./unifi-controller/unifi-controller-types"; export * from "./unifi-os/unifi-os-types"; export * from "./interfaces/unifi-os-summary/unifi-os-summary-integration"; +export * from "./unifi-site-manager/unifi-site-manager-types"; +export * from "./interfaces/unifi-site-manager/unifi-site-manager-integration"; export * from "./opnsense/opnsense-types"; export * from "./interfaces/media-releases"; export * from "./coolify/coolify-types"; diff --git a/packages/integrations/src/unifi-site-manager/test/unifi-site-manager-integration.spec.ts b/packages/integrations/src/unifi-site-manager/test/unifi-site-manager-integration.spec.ts new file mode 100644 index 000000000..d99da37f8 --- /dev/null +++ b/packages/integrations/src/unifi-site-manager/test/unifi-site-manager-integration.spec.ts @@ -0,0 +1,142 @@ +// @vitest-environment node +import { Response } from "undici"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +vi.hoisted(() => { + process.env.SKIP_ENV_VALIDATION = "true"; + process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1"; +}); + +import * as undici from "undici"; + +import type { IntegrationSecret } from "../../base/types"; +import { UnifiSiteManagerIntegration } from "../unifi-site-manager-integration"; + +vi.mock("undici", async (importOriginal) => ({ + ...(await importOriginal()), + fetch: vi.fn(), +})); + +const mockFetch = vi.mocked(undici.fetch); + +const jsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }) as never; + +const SITES = { + data: [ + { + siteId: "site-1", + hostId: "host-1", + meta: { desc: "Example site" }, + statistics: { + percentages: { wanUptime: 99.5 }, + ispInfo: { name: "Example ISP" }, + wans: { WAN: { externalIp: "203.0.113.10" } }, + counts: { totalDevice: 6, offlineDevice: 0, pendingUpdateDevice: 1 }, + }, + }, + ], +}; + +const HOSTS = { data: [{ id: "host-1", reportedState: { deviceState: "updateAvailable" } }] }; +const METRICS = { data: { metrics: [{ siteId: "site-1", periods: [] }] } }; +const SD_WAN_CONFIGS = { data: [{ id: "config-1", name: "Example mesh" }] }; +const SD_WAN_STATUS = { + data: { + status: { + connections: [ + { sites: [{ siteId: "site-1", status: { connected: true, offline: false, ping: 12, errors: [] } }] }, + ], + sites: [{ siteId: "site-1", errors: [], warnings: [] }], + }, + }, +}; + +const route = (url: string) => { + if (url.endsWith("/v1/sites")) return jsonResponse(SITES); + if (url.endsWith("/v1/hosts")) return jsonResponse(HOSTS); + if (url.includes("isp-metrics")) return jsonResponse(METRICS); + if (url.endsWith("/v1/sd-wan-configs")) return jsonResponse(SD_WAN_CONFIGS); + if (url.includes("/sd-wan-configs/")) return jsonResponse(SD_WAN_STATUS); + return jsonResponse({}, 404); +}; + +const createIntegration = () => + new UnifiSiteManagerIntegration({ + id: "test-site-manager", + name: "Test Site Manager", + url: "https://api.ui.com", + externalUrl: null, + decryptedSecrets: [{ kind: "siteManagerApiKey", value: "cloud-key" }] as IntegrationSecret[], + }); + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe("UnifiSiteManagerIntegration getSiteOverviewAsync", () => { + test("builds one entry per site", async () => { + mockFetch.mockImplementation(async (url) => route(String(url))); + + const overview = await createIntegration().getSiteOverviewAsync(); + + expect(overview.sites).toHaveLength(1); + expect(overview.sites[0]).toMatchObject({ + siteId: "site-1", + name: "Example site", + ispName: "Example ISP", + consoleUpdateAvailable: true, + }); + expect(overview.sites[0]!.sdWan).toMatchObject({ configName: "Example mesh", connected: true, pingMs: 12 }); + }); + + /** /v1/sites porte la liste : sans elle il n'y a rien à afficher, pas « aucun site ». */ + test("propagates a failure of the sites call", async () => { + mockFetch.mockImplementation(async (url) => + String(url).endsWith("/v1/sites") ? jsonResponse({}, 500) : route(String(url)), + ); + + await expect(createIntegration().getSiteOverviewAsync()).rejects.toThrow(); + }); + + test("keeps the sites when the hosts call fails, with console state unknown", async () => { + mockFetch.mockImplementation(async (url) => + String(url).endsWith("/v1/hosts") ? jsonResponse({}, 500) : route(String(url)), + ); + + const overview = await createIntegration().getSiteOverviewAsync(); + + expect(overview.sites).toHaveLength(1); + expect(overview.sites[0]!.consoleUpdateAvailable).toBeNull(); + }); + + test("keeps the sites when the metrics call fails, with the window unknown", async () => { + mockFetch.mockImplementation(async (url) => + String(url).includes("isp-metrics") ? jsonResponse({}, 500) : route(String(url)), + ); + + const overview = await createIntegration().getSiteOverviewAsync(); + + expect(overview.sites).toHaveLength(1); + expect(overview.sites[0]!.summary.unknown).toBe(24); + }); + + test("keeps the sites when the SD-WAN calls fail, with sdWan null", async () => { + mockFetch.mockImplementation(async (url) => + String(url).includes("sd-wan") ? jsonResponse({}, 500) : route(String(url)), + ); + + const overview = await createIntegration().getSiteOverviewAsync(); + + expect(overview.sites).toHaveLength(1); + expect(overview.sites[0]!.sdWan).toBeNull(); + }); + + test("returns an empty site list for an account with no sites, without erroring", async () => { + mockFetch.mockImplementation(async (url) => + String(url).endsWith("/v1/sites") ? jsonResponse({ data: [] }) : route(String(url)), + ); + + await expect(createIntegration().getSiteOverviewAsync()).resolves.toEqual({ sites: [] }); + }); +}); diff --git a/packages/integrations/src/unifi-site-manager/test/unifi-site-manager-types.spec.ts b/packages/integrations/src/unifi-site-manager/test/unifi-site-manager-types.spec.ts index f70985fc9..6d009a030 100644 --- a/packages/integrations/src/unifi-site-manager/test/unifi-site-manager-types.spec.ts +++ b/packages/integrations/src/unifi-site-manager/test/unifi-site-manager-types.spec.ts @@ -98,7 +98,7 @@ describe("buildSdWanIndex", () => { connections: [ { sites: [{ siteId, status: { connected: true, offline: false, ping: 0, errors: [], ...overrides } }] }, ], - sites: [{ siteId, errors: [], warnings: [] }], + sites: [{ siteId, errors: [] as unknown[], warnings: [] as string[] }], }, }); diff --git a/packages/integrations/src/unifi-site-manager/unifi-site-manager-integration.ts b/packages/integrations/src/unifi-site-manager/unifi-site-manager-integration.ts new file mode 100644 index 000000000..e281bc482 --- /dev/null +++ b/packages/integrations/src/unifi-site-manager/unifi-site-manager-integration.ts @@ -0,0 +1,89 @@ +import { fetch as platformFetch } from "undici"; + +import type { IntegrationTestingInput } from "../base/integration"; +import { Integration } from "../base/integration"; +import { TestConnectionError } from "../base/test-connection/test-connection-error"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; +import type { UnifiSiteManagerIntegration as UnifiSiteManagerIntegrationInterface } from "../interfaces/unifi-site-manager/unifi-site-manager-integration"; +import type { SiteManagerFetch } from "./unifi-site-manager-api"; +import { UnifiSiteManagerApi } from "./unifi-site-manager-api"; +import type { + IspMetricsEntry, + SiteManagerHost, + UnifiSiteManagerOverview, + UnifiSiteSdWanStatus, +} from "./unifi-site-manager-types"; +import { buildSdWanIndex, mapSites } from "./unifi-site-manager-types"; + +/** + * `api.ui.com` présente un certificat public valide. On n'utilise donc PAS le + * fetch à certificats de confiance de Homarr : il ajoute toutes les autorités + * personnalisées téléversées par l'administrateur, ce qui en ferait des + * ancrages valides pour le cloud d'Ubiquiti — et exposerait la clé, qui lit + * tous les sites du compte. + */ +const fetchCloudAsync: SiteManagerFetch = async (url, init) => { + const { timeout, ...rest } = init ?? {}; + const response = await platformFetch(url, { + ...rest, + signal: timeout === undefined ? undefined : AbortSignal.timeout(timeout), + } as Parameters[1]); + // `SiteManagerFetch` (task 3) types its return as the ambient DOM `Response` + // (this file's lib includes `dom`); undici's own `Response` class is + // structurally close but not identical (its Headers iterator differs), so a + // type-only bridge is needed here. No behavior change: same object, same call. + return response as unknown as Response; +}; + +const settled = (result: PromiseSettledResult, fallback: T): T => + result.status === "fulfilled" ? result.value : fallback; + +export class UnifiSiteManagerIntegration extends Integration implements UnifiSiteManagerIntegrationInterface { + public async getSiteOverviewAsync(): Promise { + return await this.buildOverviewAsync(fetchCloudAsync); + } + + protected async testingAsync(_input: IntegrationTestingInput): Promise { + try { + await this.createApi(fetchCloudAsync).getSitesAsync(); + } catch (error) { + return TestConnectionError.UnknownResult(error); + } + + return { success: true }; + } + + private createApi(fetchAsync: SiteManagerFetch) { + return new UnifiSiteManagerApi(this.getSecretValue("siteManagerApiKey"), fetchAsync); + } + + private async buildOverviewAsync(fetchAsync: SiteManagerFetch): Promise { + const api = this.createApi(fetchAsync); + const now = new Date(); + + const [sitesResult, hostsResult] = await Promise.allSettled([api.getSitesAsync(), api.getHostsAsync()]); + + // La liste des sites porte tout : son échec doit remonter, sinon le widget + // afficherait « aucun site » alors qu'il n'a rien pu lire. + if (sitesResult.status === "rejected") throw sitesResult.reason; + + const sites = sitesResult.value; + const hosts = settled(hostsResult, []); + + // Deux enrichissements, en parallèle. Leur échec laisse leur part à + // l'inconnu — la fenêtre à « inconnu » plutôt qu'à « sain », le SD-WAN à + // `null` — et n'emporte jamais la liste des sites. + const [metricsResult, sdWanResult] = await Promise.allSettled([ + api.getIspMetricsAsync( + sites.map((site) => ({ siteId: site.siteId, hostId: site.hostId })), + now, + ), + api.getSdWanStatusesAsync(), + ]); + + const metrics = settled(metricsResult, []); + const sdWanBySite: Map = buildSdWanIndex(settled(sdWanResult, [])); + + return { sites: mapSites(sites, hosts, metrics, sdWanBySite, now) }; + } +}