diff --git a/packages/integrations/src/unifi-os/test/unifi-os-api.spec.ts b/packages/integrations/src/unifi-os/test/unifi-os-api.spec.ts new file mode 100644 index 000000000..1643dea89 --- /dev/null +++ b/packages/integrations/src/unifi-os/test/unifi-os-api.spec.ts @@ -0,0 +1,119 @@ +// @vitest-environment node +import { Response } 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 { UnifiOsApi } from "../unifi-os-api"; + +const ORIGIN = "https://console.example.test"; +const API_KEY = "test-key"; + +const jsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }) as never; + +const page = (data: unknown[], totalCount = data.length, limit = 200) => ({ + offset: 0, + limit, + count: data.length, + totalCount, + data, +}); + +const createApi = (handler: (url: string) => Promise) => { + const fetchAsync = vi.fn(async (url: string) => await handler(url)); + return { api: new UnifiOsApi(ORIGIN, API_KEY, fetchAsync as never), fetchAsync }; +}; + +describe("UnifiOsApi request shape", () => { + test("prefixes the integration path and sends the API key header", async () => { + const fetchAsync = vi.fn(async () => jsonResponse({ applicationVersion: "10.6.102" })); + const api = new UnifiOsApi(ORIGIN, API_KEY, fetchAsync as never); + + await api.getInfoAsync(); + + expect(fetchAsync).toHaveBeenCalledWith( + `${ORIGIN}/proxy/network/integration/v1/info`, + expect.objectContaining({ headers: expect.objectContaining({ "X-API-KEY": API_KEY }) }), + ); + }); + + test("does not double a slash when the origin has a trailing one", async () => { + const fetchAsync = vi.fn(async () => jsonResponse({ applicationVersion: "10.6.102" })); + const api = new UnifiOsApi(`${ORIGIN}/`, API_KEY, fetchAsync as never); + + await api.getInfoAsync(); + + expect(fetchAsync.mock.calls[0]?.[0]).toBe(`${ORIGIN}/proxy/network/integration/v1/info`); + }); + + test("throws ResponseError on a non-ok status instead of parsing the body", async () => { + const { api } = createApi(async () => jsonResponse({ error: { code: 401 } }, 401)); + + await expect(api.getInfoAsync()).rejects.toBeInstanceOf(ResponseError); + }); +}); + +describe("UnifiOsApi getClientCountsAsync", () => { + test("counts from a single page when totalCount fits under the API limit", async () => { + const { api, fetchAsync } = createApi(async () => + jsonResponse( + page([ + { id: "1", name: "a", type: "WIRED", access: { type: "DEFAULT" } }, + { id: "2", name: "b", type: "WIRELESS", access: { type: "GUEST" } }, + ]), + ), + ); + + const counts = await api.getClientCountsAsync("site-1"); + + expect(counts).toEqual({ total: 2, wired: 1, wireless: 1, vpn: 0, guests: 1 }); + expect(fetchAsync).toHaveBeenCalledTimes(1); + }); + + test("switches to filtered count queries when the site exceeds the 200 limit", async () => { + const requested: string[] = []; + const { api } = createApi(async (url) => { + requested.push(url); + if (!url.includes("filter=")) return jsonResponse(page([], 512)); + if (url.includes("WIRED")) return jsonResponse(page([], 400, 1)); + if (url.includes("WIRELESS")) return jsonResponse(page([], 100, 1)); + if (url.includes("TELEPORT")) return jsonResponse(page([], 2, 1)); + if (url.includes("VPN")) return jsonResponse(page([], 10, 1)); + return jsonResponse(page([], 5, 1)); + }); + + const counts = await api.getClientCountsAsync("site-1"); + + expect(counts).toEqual({ total: 512, wired: 400, wireless: 100, vpn: 12, guests: 5 }); + expect(requested.some((url) => url.includes("and("))).toBe(false); + expect(requested.filter((url) => url.includes("filter=")).length).toBe(5); + }); + + test("uses the prefix filter syntax the API actually accepts", async () => { + const requested: string[] = []; + const { api } = createApi(async (url) => { + requested.push(url); + return jsonResponse(page([], url.includes("filter=") ? 1 : 512, 1)); + }); + + await api.getClientCountsAsync("site-1"); + + const guestQuery = requested.find((url) => url.includes("GUEST")); + expect(decodeURIComponent(guestQuery ?? "")).toContain("access.type.eq('GUEST')"); + expect(decodeURIComponent(guestQuery ?? "")).not.toContain(" and "); + }); +}); + +describe("UnifiOsApi getFirstSiteAsync", () => { + test("returns null for a console exposing no site rather than throwing", async () => { + const { api } = createApi(async () => jsonResponse(page([]))); + + expect(await api.getFirstSiteAsync()).toBeNull(); + }); +}); diff --git a/packages/integrations/src/unifi-os/unifi-os-api.ts b/packages/integrations/src/unifi-os/unifi-os-api.ts new file mode 100644 index 000000000..2f63c430e --- /dev/null +++ b/packages/integrations/src/unifi-os/unifi-os-api.ts @@ -0,0 +1,119 @@ +import { ResponseError } from "@homarr/common/server"; +import type { z } from "zod/v4"; + +import type { UnifiOsClientCounts, UnifiOsDevice, UnifiOsDeviceStatistics, UnifiOsSite } from "./unifi-os-types"; +import { + countClients, + createUnifiOsPageSchema, + parseUnifiOsAsync, + unifiOsClientSchema, + unifiOsDeviceSchema, + unifiOsDeviceStatisticsSchema, + unifiOsInfoSchema, + unifiOsSiteSchema, + unifiOsVpnServerSchema, +} from "./unifi-os-types"; + +export type UnifiOsFetch = (url: string, init?: RequestInit & { timeout?: number }) => Promise; + +const INTEGRATION_PATH = "/proxy/network/integration/v1"; +const REQUEST_TIMEOUT_MS = 10_000; + +/** Plafond imposé par l'API : demander davantage renvoie quand même 200. */ +const MAX_PAGE_LIMIT = 200; + +const PARSE_ERROR_MESSAGE = "Invalid UniFi OS response"; + +const clientPageSchema = createUnifiOsPageSchema(unifiOsClientSchema); +const devicePageSchema = createUnifiOsPageSchema(unifiOsDeviceSchema); +const sitePageSchema = createUnifiOsPageSchema(unifiOsSiteSchema); +const vpnServerPageSchema = createUnifiOsPageSchema(unifiOsVpnServerSchema); + +/** + * Client de l'API d'intégration locale d'UniFi OS. + * + * Ne connaît ni Homarr ni la notion d'intégration : il ne sait que parler à une + * console. Le `fetch` est injecté pour que les tests n'aient pas à mocker un module. + */ +export class UnifiOsApi { + private readonly baseUrl: string; + + constructor( + origin: string, + private readonly apiKey: string, + private readonly fetchAsync: UnifiOsFetch, + ) { + this.baseUrl = `${origin.replace(/\/+$/, "")}${INTEGRATION_PATH}`; + } + + public async getInfoAsync(): Promise<{ applicationVersion: string }> { + return await this.requestAsync("/info", unifiOsInfoSchema); + } + + public async getFirstSiteAsync(): Promise { + const page = await this.requestAsync("/sites", sitePageSchema); + return page.data[0] ?? null; + } + + public async getDevicesAsync(siteId: string): Promise { + const page = await this.requestAsync( + `/sites/${encodeURIComponent(siteId)}/devices?limit=${MAX_PAGE_LIMIT}`, + devicePageSchema, + ); + return page.data; + } + + public async getDeviceStatisticsAsync(siteId: string, deviceId: string): Promise { + return await this.requestAsync( + `/sites/${encodeURIComponent(siteId)}/devices/${encodeURIComponent(deviceId)}/statistics/latest`, + unifiOsDeviceStatisticsSchema, + ); + } + + public async getVpnServersAsync(siteId: string): Promise<{ enabled: boolean }[]> { + const page = await this.requestAsync(`/sites/${encodeURIComponent(siteId)}/vpn/servers`, vpnServerPageSchema); + return page.data; + } + + /** + * Sous le plafond de 200 clients, une seule page suffit et le comptage se fait + * en mémoire. Au-delà, rapatrier tous les enregistrements pour n'en tirer que + * des compteurs coûterait plus cher que cinq requêtes bornées. + */ + public async getClientCountsAsync(siteId: string): Promise { + const path = `/sites/${encodeURIComponent(siteId)}/clients`; + const firstPage = await this.requestAsync(`${path}?limit=${MAX_PAGE_LIMIT}`, clientPageSchema); + + if (firstPage.totalCount <= MAX_PAGE_LIMIT) { + return countClients(firstPage.data); + } + + const countByFilterAsync = async (filter: string) => { + const page = await this.requestAsync(`${path}?limit=1&filter=${encodeURIComponent(filter)}`, clientPageSchema); + return page.totalCount; + }; + + const [wired, wireless, vpn, teleport, guests] = await Promise.all([ + countByFilterAsync("type.eq('WIRED')"), + countByFilterAsync("type.eq('WIRELESS')"), + countByFilterAsync("type.eq('VPN')"), + countByFilterAsync("type.eq('TELEPORT')"), + countByFilterAsync("access.type.eq('GUEST')"), + ]); + + return { total: firstPage.totalCount, wired, wireless, vpn: vpn + teleport, guests }; + } + + private async requestAsync(path: string, schema: z.ZodType): Promise { + const response = await this.fetchAsync(`${this.baseUrl}${path}`, { + headers: { "X-API-KEY": this.apiKey, Accept: "application/json" }, + timeout: REQUEST_TIMEOUT_MS, + }); + + if (!response.ok) { + throw new ResponseError(response); + } + + return await parseUnifiOsAsync(await response.json(), schema, PARSE_ERROR_MESSAGE); + } +}