From be4a5da40536db7c26cd58cfdce63b2f3ab1a3d0 Mon Sep 17 00:00:00 2001 From: Guillaume Juge Date: Tue, 1 Sep 2026 16:28:04 +0200 Subject: [PATCH] fix(dns): match apex records stored under any apex spelling toSource always writes the apex as ".", but listRecords already accepted "" and "@" as apex spellings on read. The upsert lookup compared sources strictly, so a record stored under one of the other spellings would not have matched and the upsert would have created a duplicate apex record instead of updating it. Normalize the candidate's source before comparing, so read and match agree. Reported by Greptile on #5257. Co-Authored-By: Claude Opus 5 (1M context) --- apps/dokploy/__test__/dns/infomaniak.test.ts | 23 ++++++++++++++++++++ packages/server/src/utils/dns/infomaniak.ts | 9 +++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/apps/dokploy/__test__/dns/infomaniak.test.ts b/apps/dokploy/__test__/dns/infomaniak.test.ts index 1a92f0efe..4d7bddfbd 100644 --- a/apps/dokploy/__test__/dns/infomaniak.test.ts +++ b/apps/dokploy/__test__/dns/infomaniak.test.ts @@ -213,6 +213,29 @@ describe("infomaniakClient.upsertRecord", () => { expect(lastBody().source).toBe("."); }); + it.each([".", "", "@"])( + "matches an existing apex record stored with a %s source", + async (source) => { + mockFetch + .mockResolvedValueOnce( + ikSuccess([ + { id: 8, type: "A", source, target: "1.1.1.1", ttl: 3600 }, + ]), + ) + .mockResolvedValueOnce(ikSuccess({ id: 8 })); + + const result = await infomaniakClient.upsertRecord(config, { + zoneId: "example.com", + type: "A", + name: "example.com", + content: "1.2.3.4", + }); + + expect(result).toEqual({ id: "8" }); + expect(lastCall()[1].method).toBe("PUT"); + }, + ); + it("matches the existing apex record instead of creating a duplicate", async () => { mockFetch .mockResolvedValueOnce( diff --git a/packages/server/src/utils/dns/infomaniak.ts b/packages/server/src/utils/dns/infomaniak.ts index b187c49fe..b68b56c43 100644 --- a/packages/server/src/utils/dns/infomaniak.ts +++ b/packages/server/src/utils/dns/infomaniak.ts @@ -71,6 +71,12 @@ const toSource = (name: string, zone: string) => { const toFqdn = (source: string, zone: string) => APEX_SOURCES.has(source) ? zone : `${source}.${zone}`; +// toSource always writes the apex as ".", so an existing record stored under one +// of the other apex spellings has to normalize to the same thing before it can +// be matched. +const normalizeSource = (source: string) => + APEX_SOURCES.has(source) ? "." : source; + // TXT targets are stored quoted; keep Dokploy's view of them unquoted so that // editing a record does not stack a new pair of quotes on every save. const unquoteTarget = (target: string) => { @@ -146,7 +152,8 @@ export const infomaniakClient: DnsClient = { const existing = await listZoneRecords(config, record.zoneId); const match = existing.find( (candidate) => - candidate.type === record.type && candidate.source === source, + candidate.type === record.type && + normalizeSource(candidate.source) === source, ); const body = JSON.stringify(recordPayload(record, record.zoneId));