fix(dns): paginate the documented Infomaniak products endpoint

listZones used /1/product, which lego uses but the API docs don't: it is the
legacy singular route, it returns no pagination metadata, and it 401s for at
least some tokens. The documented /1/products returns `total`, `pages`,
`items_per_page` and `page`, and defaults to 15 domains per page, so an account
with more domains than that would silently lose zones.

Switch to the plural endpoint and walk every page. Verified against a live
account: forcing per_page=2 collects all 5 domains across 3 pages with no
duplicates.

Also inline the single-use createdId helper.

Both reported by @narcisonunez on #5257.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guillaume Juge
2026-09-01 21:02:54 +02:00
parent be4a5da405
commit 59a74849a8
2 changed files with 78 additions and 20 deletions
+38 -8
View File
@@ -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",
);
});
});
+40 -12
View File
@@ -8,6 +8,9 @@ interface InfomaniakResponse<T> {
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 <T>(
const ikRequest = async <T>(
config: InfomaniakConfig,
path: string,
init: RequestInit = {},
): Promise<T> => {
): Promise<InfomaniakResponse<T>> => {
const response = await dnsFetch(`${INFOMANIAK_API}${path}`, {
...init,
headers: {
@@ -51,9 +54,15 @@ const ikFetch = async <T>(
}`,
);
}
return body.data as T;
return body;
};
const ikFetch = async <T>(
config: InfomaniakConfig,
path: string,
init: RequestInit = {},
): Promise<T> => (await ikRequest<T>(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<InfomaniakDomain[]>(
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<InfomaniakRecord[]>(
@@ -125,10 +150,7 @@ const listZoneRecords = async (config: InfomaniakConfig, zoneId: string) =>
export const infomaniakClient: DnsClient<InfomaniakConfig> = {
async listZones(config) {
const domains = await ikFetch<InfomaniakDomain[]>(
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<InfomaniakConfig> = {
`/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<InfomaniakConfig> = {
},
async testConnection(config) {
await ikFetch(config, "/1/product?service_name=domain");
await ikFetch(config, "/1/products?service_name=domain&per_page=1");
},
};