diff --git a/apps/dokploy/__test__/dns/infomaniak.test.ts b/apps/dokploy/__test__/dns/infomaniak.test.ts index 1271d400d..e04cb08b9 100644 --- a/apps/dokploy/__test__/dns/infomaniak.test.ts +++ b/apps/dokploy/__test__/dns/infomaniak.test.ts @@ -326,6 +326,58 @@ describe("infomaniakClient.upsertRecord", () => { expect(lastBody().target).toBe('"token-value"'); }); + + it("queries the API with a source and type filter instead of the whole zone", async () => { + mockFetch + .mockResolvedValueOnce(ikSuccess([])) + .mockResolvedValueOnce(ikSuccess({ id: 50 })); + + await infomaniakClient.upsertRecord(config, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + }); + + const [url] = mockFetch.mock.calls[0] as [string]; + expect(url).toContain("filter%5Bsource%5D=app"); + expect(url).toContain("filter%5Btypes%5D%5B%5D=A"); + }); + + it("ignores a partial filter hit rather than overwriting a different record", async () => { + // filter[source] matches substrings: asking for "auto" also returns + // "autoconfig" and "autodiscover". Trusting it would overwrite one of them. + mockFetch + .mockResolvedValueOnce( + ikSuccess([ + { + id: 61, + type: "CNAME", + source: "autoconfig", + target: "a.example.net", + ttl: 300, + }, + { + id: 62, + type: "CNAME", + source: "autodiscover", + target: "b.example.net", + ttl: 300, + }, + ]), + ) + .mockResolvedValueOnce(ikSuccess({ id: 63 })); + + const result = await infomaniakClient.upsertRecord(config, { + zoneId: "example.com", + type: "CNAME", + name: "auto.example.com", + content: "c.example.net", + }); + + expect(result).toEqual({ id: "63" }); + expect(lastCall()[1].method).toBe("POST"); + }); }); describe("infomaniakClient.updateRecord", () => { diff --git a/packages/server/src/utils/dns/infomaniak.ts b/packages/server/src/utils/dns/infomaniak.ts index 56c0a8231..f7c4204d3 100644 --- a/packages/server/src/utils/dns/infomaniak.ts +++ b/packages/server/src/utils/dns/infomaniak.ts @@ -148,6 +148,31 @@ const listZoneRecords = async (config: InfomaniakConfig, zoneId: string) => `/2/zones/${encodeURIComponent(zoneId)}/records?with=records_description`, ); +// The API filters server-side, which avoids pulling a whole zone just to find +// one record. The match is still checked here: filter[source] is documented with +// a bare subdomain example, so nothing guarantees it compares exactly the way +// toSource writes the apex, and a filter that silently over-matches would +// otherwise turn an update into a duplicate. +const findRecord = async ( + config: InfomaniakConfig, + zoneId: string, + type: string, + source: string, +) => { + const query = new URLSearchParams({ + "filter[source]": source, + "filter[types][]": type, + }); + const candidates = await ikFetch( + config, + `/2/zones/${encodeURIComponent(zoneId)}/records?${query}`, + ); + return candidates.find( + (candidate) => + candidate.type === type && normalizeSource(candidate.source) === source, + ); +}; + export const infomaniakClient: DnsClient = { async listZones(config) { const domains = await listDomainProducts(config); @@ -171,12 +196,7 @@ export const infomaniakClient: DnsClient = { async upsertRecord(config, record) { const source = toSource(record.name, record.zoneId); - const existing = await listZoneRecords(config, record.zoneId); - const match = existing.find( - (candidate) => - candidate.type === record.type && - normalizeSource(candidate.source) === source, - ); + const match = await findRecord(config, record.zoneId, record.type, source); const body = JSON.stringify(recordPayload(record, record.zoneId)); const zone = encodeURIComponent(record.zoneId);