diff --git a/packages/integrations/src/unifi-site-manager/test/unifi-site-manager-api.spec.ts b/packages/integrations/src/unifi-site-manager/test/unifi-site-manager-api.spec.ts new file mode 100644 index 000000000..3faa4e553 --- /dev/null +++ b/packages/integrations/src/unifi-site-manager/test/unifi-site-manager-api.spec.ts @@ -0,0 +1,146 @@ +// @vitest-environment node +import { Response, type RequestInit } from "undici"; +import { describe, expect, test, vi } from "vitest"; + +vi.hoisted(() => { + process.env.SKIP_ENV_VALIDATION = "true"; + process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1"; +}); + +import { ResponseError } from "@homarr/common/server"; + +import { MAX_SD_WAN_CONFIGS, UnifiSiteManagerApi } from "../unifi-site-manager-api"; + +const API_KEY = "cloud-key"; +const NOW = new Date("2026-09-09T20:00:00Z"); + +const jsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }) as never; + +const createApi = (handler: (url: string, init?: RequestInit) => Promise) => { + const fetchAsync = vi.fn(async (url: string, init?: RequestInit) => await handler(url, init)); + return { api: new UnifiSiteManagerApi(API_KEY, fetchAsync as never), fetchAsync }; +}; + +describe("UnifiSiteManagerApi request shape", () => { + test("calls the cloud host and sends the API key", async () => { + const { api, fetchAsync } = createApi(async () => jsonResponse({ data: [] })); + + await api.getSitesAsync(); + + expect(fetchAsync).toHaveBeenCalledWith( + "https://api.ui.com/v1/sites", + expect.objectContaining({ headers: expect.objectContaining({ "X-API-KEY": API_KEY }) }), + ); + }); + + test("throws ResponseError on a non-ok status instead of parsing the body", async () => { + const { api } = createApi(async () => jsonResponse({ message: "unauthorized" }, 401)); + + await expect(api.getSitesAsync()).rejects.toBeInstanceOf(ResponseError); + }); +}); + +describe("UnifiSiteManagerApi getSdWanStatusesAsync", () => { + const emptyStatus = { data: { status: { connections: [], sites: [] } } }; + + test("fetches the status of every configuration it lists", async () => { + const requested: string[] = []; + const { api } = createApi(async (url) => { + requested.push(url); + if (url.endsWith("/v1/sd-wan-configs")) { + return jsonResponse({ + data: [ + { id: "c1", name: "First" }, + { id: "c2", name: "Second" }, + ], + }); + } + return jsonResponse(emptyStatus); + }); + + const statuses = await api.getSdWanStatusesAsync(); + + expect(statuses.map((entry) => entry.configName)).toEqual(["First", "Second"]); + expect(requested).toContain("https://api.ui.com/v1/sd-wan-configs/c1/status"); + expect(requested).toContain("https://api.ui.com/v1/sd-wan-configs/c2/status"); + }); + + /** Sans borne, un compte à dix configurations produirait onze requêtes par cycle. */ + test("stops after MAX_SD_WAN_CONFIGS configurations", async () => { + const { api, fetchAsync } = createApi(async (url) => + url.endsWith("/v1/sd-wan-configs") + ? jsonResponse({ + data: Array.from({ length: 9 }, (_, index) => ({ id: `c${index}`, name: `Config ${index}` })), + }) + : jsonResponse(emptyStatus), + ); + + const statuses = await api.getSdWanStatusesAsync(); + + expect(statuses).toHaveLength(MAX_SD_WAN_CONFIGS); + expect(fetchAsync).toHaveBeenCalledTimes(MAX_SD_WAN_CONFIGS + 1); + }); + + test("skips a configuration whose status call fails rather than losing the others", async () => { + const { api } = createApi(async (url) => { + if (url.endsWith("/v1/sd-wan-configs")) { + return jsonResponse({ + data: [ + { id: "c1", name: "First" }, + { id: "c2", name: "Second" }, + ], + }); + } + return url.includes("/c1/") ? jsonResponse({}, 500) : jsonResponse(emptyStatus); + }); + + const statuses = await api.getSdWanStatusesAsync(); + + expect(statuses.map((entry) => entry.configName)).toEqual(["Second"]); + }); + + test("returns an empty list when the account has no configuration", async () => { + const { api } = createApi(async () => jsonResponse({ data: [] })); + + expect(await api.getSdWanStatusesAsync()).toEqual([]); + }); +}); + +describe("UnifiSiteManagerApi getIspMetricsAsync", () => { + test("asks for every site in a single bounded query", async () => { + let body: { sites: { siteId: string; beginTimestamp: string; endTimestamp: string }[] } | null = null; + const { api } = createApi(async (_url, init) => { + body = JSON.parse(String(init?.body)) as typeof body; + return jsonResponse({ data: { metrics: [] } }); + }); + + await api.getIspMetricsAsync( + [ + { siteId: "a", hostId: "h1" }, + { siteId: "b", hostId: "h2" }, + ], + NOW, + ); + + expect(body!.sites).toHaveLength(2); + const [first] = body!.sites; + const spanHours = (new Date(first!.endTimestamp).getTime() - new Date(first!.beginTimestamp).getTime()) / 3_600_000; + expect(spanHours).toBeGreaterThanOrEqual(24); + }); + + test("returns an empty list rather than calling the cloud when there is no site", async () => { + const { api, fetchAsync } = createApi(async () => jsonResponse({ data: { metrics: [] } })); + + expect(await api.getIspMetricsAsync([], NOW)).toEqual([]); + expect(fetchAsync).not.toHaveBeenCalled(); + }); + + test("uses the hourly granularity, not the five-minute one", async () => { + const { api, fetchAsync } = createApi(async () => jsonResponse({ data: { metrics: [] } })); + + await api.getIspMetricsAsync([{ siteId: "a" }], NOW); + + expect(String(fetchAsync.mock.calls[0]?.[0])).toBe("https://api.ui.com/v1/isp-metrics/1h/query"); + }); +}); diff --git a/packages/integrations/src/unifi-site-manager/unifi-site-manager-api.ts b/packages/integrations/src/unifi-site-manager/unifi-site-manager-api.ts new file mode 100644 index 000000000..21987f30b --- /dev/null +++ b/packages/integrations/src/unifi-site-manager/unifi-site-manager-api.ts @@ -0,0 +1,119 @@ +import { ResponseError } from "@homarr/common/server"; +import type { z } from "zod/v4"; + +import { parseSiteManagerAsync } from "./unifi-site-manager-parse"; +import type { IspMetricsEntry, SdWanStatus, SiteManagerHost, SiteManagerSite } from "./unifi-site-manager-types"; +import { + HEALTH_WINDOW_HOURS, + ispMetricsResponseSchema, + sdWanConfigListSchema, + sdWanStatusSchema, + siteManagerHostListSchema, + siteManagerSiteListSchema, +} from "./unifi-site-manager-types"; + +/** L'API cloud d'Ubiquiti. Jamais configurable : ce n'est pas une console locale. */ +const BASE_URL = "https://api.ui.com"; +const REQUEST_TIMEOUT_MS = 15_000; + +/** + * Le statut SD-WAN est indexé par configuration : il faut d'abord les lister, + * puis interroger chacune. Sans borne, un compte à dix configurations + * produirait onze requêtes par rafraîchissement. + */ +export const MAX_SD_WAN_CONFIGS = 5; + +export type SiteManagerFetch = (url: string, init?: RequestInit & { timeout?: number }) => Promise; + +export class UnifiSiteManagerApi { + constructor( + private readonly apiKey: string, + private readonly fetchAsync: SiteManagerFetch, + ) {} + + public async getSitesAsync(): Promise { + const parsed = await this.requestAsync( + "/v1/sites", + siteManagerSiteListSchema, + "Invalid Site Manager sites response", + ); + return parsed.data; + } + + public async getHostsAsync(): Promise { + const parsed = await this.requestAsync( + "/v1/hosts", + siteManagerHostListSchema, + "Invalid Site Manager hosts response", + ); + return parsed.data; + } + + /** + * Granularité horaire sur la fenêtre de la bande de santé, tous les sites dans + * une seule requête bornée. La variante non bornée pèse 216 Ko (mesuré). + */ + public async getIspMetricsAsync( + sites: { siteId: string; hostId?: string }[], + endingAt: Date, + ): Promise { + if (sites.length === 0) return []; + + const beginTimestamp = new Date(endingAt.getTime() - HEALTH_WINDOW_HOURS * 3_600_000).toISOString(); + const endTimestamp = endingAt.toISOString(); + + const response = await this.fetchAsync(`${BASE_URL}/v1/isp-metrics/1h/query`, { + method: "POST", + headers: { "X-API-KEY": this.apiKey, Accept: "application/json", "Content-Type": "application/json" }, + timeout: REQUEST_TIMEOUT_MS, + body: JSON.stringify({ + sites: sites.map((site) => ({ hostId: site.hostId, siteId: site.siteId, beginTimestamp, endTimestamp })), + }), + }); + + if (!response.ok) throw new ResponseError(response); + + const parsed = await parseSiteManagerAsync( + await response.json(), + ispMetricsResponseSchema, + "Invalid Site Manager ISP metrics response", + ); + return parsed.data.metrics; + } + + public async getSdWanStatusesAsync(): Promise<{ configName: string; status: SdWanStatus["data"]["status"] }[]> { + const configs = await this.requestAsync( + "/v1/sd-wan-configs", + sdWanConfigListSchema, + "Invalid Site Manager SD-WAN configuration list", + ); + + const statuses: { configName: string; status: SdWanStatus["data"]["status"] }[] = []; + + for (const config of configs.data.slice(0, MAX_SD_WAN_CONFIGS)) { + try { + const parsed = await this.requestAsync( + `/v1/sd-wan-configs/${encodeURIComponent(config.id)}/status`, + sdWanStatusSchema, + "Invalid Site Manager SD-WAN status", + ); + statuses.push({ configName: config.name ?? config.id, status: parsed.data.status }); + } catch { + // Une configuration illisible ne doit pas emporter les autres. + } + } + + return statuses; + } + + private async requestAsync(path: string, schema: z.ZodType, message: string): Promise { + const response = await this.fetchAsync(`${BASE_URL}${path}`, { + headers: { "X-API-KEY": this.apiKey, Accept: "application/json" }, + timeout: REQUEST_TIMEOUT_MS, + }); + + if (!response.ok) throw new ResponseError(response); + + return await parseSiteManagerAsync(await response.json(), schema, message); + } +}