diff --git a/apps/dokploy/__test__/dns/infomaniak.test.ts b/apps/dokploy/__test__/dns/infomaniak.test.ts index 4d7bddfbd..1271d400d 100644 --- a/apps/dokploy/__test__/dns/infomaniak.test.ts +++ b/apps/dokploy/__test__/dns/infomaniak.test.ts @@ -14,6 +14,9 @@ const jsonResponse = (body: unknown, ok = true, status = 200) => const ikSuccess = (data: unknown) => jsonResponse({ result: "success", data }); +const ikPage = (data: unknown, page: number, pages: number) => + jsonResponse({ result: "success", data, page, pages }); + const ikError = (description: string, status = 400) => jsonResponse( { result: "error", error: { code: "not_authorized", description } }, @@ -38,10 +41,14 @@ beforeEach(() => { describe("infomaniakClient.listZones", () => { it("exposes each domain product as a zone keyed by its name", async () => { mockFetch.mockResolvedValue( - ikSuccess([ - { id: 1, customer_name: "example.com" }, - { id: 2, customer_name: "example.ch" }, - ]), + ikPage( + [ + { id: 1, customer_name: "example.com" }, + { id: 2, customer_name: "example.ch" }, + ], + 1, + 1, + ), ); const zones = await infomaniakClient.listZones(config); @@ -51,14 +58,37 @@ describe("infomaniakClient.listZones", () => { { id: "example.ch", name: "example.ch" }, ]); const [url, init] = lastCall(); - expect(url).toBe( - "https://api.infomaniak.com/1/product?service_name=domain", - ); + // The documented endpoint is the plural one; the singular is legacy and + // returns no pagination metadata at all. + expect(url).toContain("/1/products?service_name=domain"); + expect(url).toContain("page=1"); expect(init.headers).toMatchObject({ Authorization: "Bearer ik_test_token", }); }); + it("walks every page so accounts with many domains keep all their zones", async () => { + mockFetch + .mockResolvedValueOnce(ikPage([{ id: 1, customer_name: "a.com" }], 1, 3)) + .mockResolvedValueOnce(ikPage([{ id: 2, customer_name: "b.com" }], 2, 3)) + .mockResolvedValueOnce(ikPage([{ id: 3, customer_name: "c.com" }], 3, 3)); + + const zones = await infomaniakClient.listZones(config); + + expect(zones.map((zone) => zone.name)).toEqual(["a.com", "b.com", "c.com"]); + expect(mockFetch).toHaveBeenCalledTimes(3); + expect((mockFetch.mock.calls[2] as [string])[0]).toContain("page=3"); + }); + + it("stops after a single page when the response has no pagination", async () => { + mockFetch.mockResolvedValue(ikSuccess([{ id: 1, customer_name: "a.com" }])); + + const zones = await infomaniakClient.listZones(config); + + expect(zones).toEqual([{ id: "a.com", name: "a.com" }]); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + it("propagates the API error description", async () => { mockFetch.mockResolvedValue(ikError("Authorization required", 401)); @@ -376,7 +406,7 @@ describe("infomaniakClient.testConnection", () => { mockFetch.mockResolvedValue(ikError("Authorization required", 401)); await expect(infomaniakClient.testConnection(config)).rejects.toThrow( - "Infomaniak: request to /1/product?service_name=domain failed: Authorization required", + "Infomaniak: request to /1/products?service_name=domain&per_page=1 failed: Authorization required", ); }); }); diff --git a/packages/server/src/utils/dns/infomaniak.ts b/packages/server/src/utils/dns/infomaniak.ts index b68b56c43..56c0a8231 100644 --- a/packages/server/src/utils/dns/infomaniak.ts +++ b/packages/server/src/utils/dns/infomaniak.ts @@ -8,6 +8,9 @@ interface InfomaniakResponse { result: "success" | "error"; data?: T; error?: { code?: string; description?: string }; + page?: number; + pages?: number; + total?: number; } interface InfomaniakRecord { @@ -28,11 +31,11 @@ const INFOMANIAK_API = "https://api.infomaniak.com"; // Infomaniak requires a TTL on every record, within a 60..86400 range. const DEFAULT_TTL = 300; -const ikFetch = async ( +const ikRequest = async ( config: InfomaniakConfig, path: string, init: RequestInit = {}, -): Promise => { +): Promise> => { const response = await dnsFetch(`${INFOMANIAK_API}${path}`, { ...init, headers: { @@ -51,9 +54,15 @@ const ikFetch = async ( }`, ); } - return body.data as T; + return body; }; +const ikFetch = async ( + config: InfomaniakConfig, + path: string, + init: RequestInit = {}, +): Promise => (await ikRequest(config, path, init)).data as T; + // Infomaniak's "source" holds the subdomain only, relative to the zone. The apex // is a bare root dot; "" and "@" are accepted too so a hand-written record still // round-trips. @@ -113,9 +122,25 @@ const recordPayload = ( ttl: record.ttl ?? DEFAULT_TTL, }); -// The API returns the created record, but older responses only carry its id. -const createdId = (data: InfomaniakRecord | string | number) => - typeof data === "object" && data !== null ? String(data.id) : String(data); +const PRODUCTS_PER_PAGE = 100; + +// The products endpoint paginates — 15 per page by default — so an account with +// more domains than fit on one page would otherwise silently lose zones. +const listDomainProducts = async (config: InfomaniakConfig) => { + const domains: InfomaniakDomain[] = []; + let page = 1; + while (true) { + const body = await ikRequest( + config, + `/1/products?service_name=domain&page=${page}&per_page=${PRODUCTS_PER_PAGE}`, + ); + domains.push(...(body.data ?? [])); + if (page >= (body.pages ?? 1)) { + return domains; + } + page += 1; + } +}; const listZoneRecords = async (config: InfomaniakConfig, zoneId: string) => await ikFetch( @@ -125,10 +150,7 @@ const listZoneRecords = async (config: InfomaniakConfig, zoneId: string) => export const infomaniakClient: DnsClient = { async listZones(config) { - const domains = await ikFetch( - config, - "/1/product?service_name=domain", - ); + const domains = await listDomainProducts(config); // The v2 record endpoints are keyed by zone name, not by product id. return domains.map((domain) => ({ id: domain.customer_name, @@ -172,7 +194,13 @@ export const infomaniakClient: DnsClient = { `/2/zones/${zone}/records`, { method: "POST", body }, ); - return { id: createdId(created) }; + // The API returns the created record, but older responses only carry its id. + return { + id: + typeof created === "object" && created !== null + ? String(created.id) + : String(created), + }; }, async updateRecord(config, zoneId, recordId, record) { @@ -193,6 +221,6 @@ export const infomaniakClient: DnsClient = { }, async testConnection(config) { - await ikFetch(config, "/1/product?service_name=domain"); + await ikFetch(config, "/1/products?service_name=domain&per_page=1"); }, };