mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-15 21:31:11 +00:00
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:
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user