diff --git a/apps/dokploy/__test__/dns/cloudflare.test.ts b/apps/dokploy/__test__/dns/cloudflare.test.ts index f27e7bc60..8edc07885 100644 --- a/apps/dokploy/__test__/dns/cloudflare.test.ts +++ b/apps/dokploy/__test__/dns/cloudflare.test.ts @@ -323,9 +323,11 @@ describe("cloudflareClient.upsertRecord", () => { expect(createInit.method).toBe("POST"); }); - it("updates the existing record instead of creating a duplicate", async () => { + it("updates the existing record when content matches", async () => { mockFetch - .mockResolvedValueOnce(cfSuccess([{ id: "existing-1" }])) + .mockResolvedValueOnce( + cfSuccess([{ id: "existing-1", type: "A", content: "5.6.7.8" }]), + ) .mockResolvedValueOnce(cfSuccess({ id: "existing-1" })); const result = await cloudflareClient.upsertRecord(config, { @@ -344,6 +346,25 @@ describe("cloudflareClient.upsertRecord", () => { expect(updateInit.method).toBe("PUT"); }); + it("creates a new record when content differs from existing", async () => { + mockFetch + .mockResolvedValueOnce( + cfSuccess([{ id: "existing-1", type: "A", content: "1.1.1.1" }]), + ) + .mockResolvedValueOnce(cfSuccess({ id: "new-2" })); + + const result = await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "A", + name: "app.example.com", + content: "5.6.7.8", + }); + + expect(result).toEqual({ id: "new-2" }); + const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect(createInit.method).toBe("POST"); + }); + it("defaults ttl to 1 (automatic) when not provided", async () => { mockFetch .mockResolvedValueOnce(cfSuccess([])) diff --git a/apps/dokploy/__test__/dns/infomaniak.test.ts b/apps/dokploy/__test__/dns/infomaniak.test.ts index 1271d400d..d8bfe1f18 100644 --- a/apps/dokploy/__test__/dns/infomaniak.test.ts +++ b/apps/dokploy/__test__/dns/infomaniak.test.ts @@ -203,11 +203,11 @@ describe("infomaniakClient.upsertRecord", () => { }); }); - it("updates the existing record instead of creating a duplicate", async () => { + it("updates the existing record when content matches", async () => { mockFetch .mockResolvedValueOnce( ikSuccess([ - { id: 7, type: "A", source: "app", target: "1.1.1.1", ttl: 300 }, + { id: 7, type: "A", source: "app", target: "1.2.3.4", ttl: 300 }, ]), ) .mockResolvedValueOnce(ikSuccess({ id: 7 })); @@ -228,6 +228,28 @@ describe("infomaniakClient.upsertRecord", () => { expect(lastBody().ttl).toBe(300); }); + it("creates a new record when content differs from existing", async () => { + mockFetch + .mockResolvedValueOnce( + ikSuccess([ + { id: 7, type: "A", source: "app", target: "1.1.1.1", ttl: 300 }, + ]), + ) + .mockResolvedValueOnce(ikSuccess({ id: 50 })); + + const result = await infomaniakClient.upsertRecord(config, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + }); + + expect(result).toEqual({ id: "50" }); + const [url, init] = lastCall(); + expect(url).toBe("https://api.infomaniak.com/2/zones/example.com/records"); + expect(init.method).toBe("POST"); + }); + it("writes a root dot as the source for an apex record and strips the trailing dot", async () => { mockFetch .mockResolvedValueOnce(ikSuccess([])) @@ -249,7 +271,7 @@ describe("infomaniakClient.upsertRecord", () => { mockFetch .mockResolvedValueOnce( ikSuccess([ - { id: 8, type: "A", source, target: "1.1.1.1", ttl: 3600 }, + { id: 8, type: "A", source, target: "1.2.3.4", ttl: 3600 }, ]), ) .mockResolvedValueOnce(ikSuccess({ id: 8 })); diff --git a/apps/dokploy/__test__/dns/ovh.test.ts b/apps/dokploy/__test__/dns/ovh.test.ts index 744716cbd..7e08d7f99 100644 --- a/apps/dokploy/__test__/dns/ovh.test.ts +++ b/apps/dokploy/__test__/dns/ovh.test.ts @@ -261,9 +261,21 @@ describe("ovhClient.upsertRecord", () => { expect(calls[2]?.[1].method).toBe("POST"); }); - it("updates the existing record instead of creating a duplicate", async () => { + it("updates the existing record when content matches", async () => { const cfg = freshConfig(); - mockApi(ovhSuccess([4]), ovhSuccess(null), ovhSuccess(null)); + mockApi( + ovhSuccess([4]), + ovhSuccess({ + id: 4, + zone: "example.com", + fieldType: "A", + subDomain: "app", + target: "1.2.3.4", + ttl: 60, + }), + ovhSuccess(null), + ovhSuccess(null), + ); const result = await ovhClient.upsertRecord(cfg, { zoneId: "example.com", @@ -274,9 +286,38 @@ describe("ovhClient.upsertRecord", () => { expect(result).toEqual({ id: "4" }); const calls = apiCalls(); - expect(calls[1]?.[0]).toContain("/domain/zone/example.com/record/4"); - expect(calls[1]?.[1].method).toBe("PUT"); - expect(calls[2]?.[0]).toContain("/refresh"); + expect(calls[2]?.[0]).toContain("/domain/zone/example.com/record/4"); + expect(calls[2]?.[1].method).toBe("PUT"); + expect(calls[3]?.[0]).toContain("/refresh"); + }); + + it("creates a new record when content differs from existing", async () => { + const cfg = freshConfig(); + mockApi( + ovhSuccess([4]), + ovhSuccess({ + id: 4, + zone: "example.com", + fieldType: "A", + subDomain: "app", + target: "1.1.1.1", + ttl: 60, + }), + ovhSuccess({ id: 10 }), + ovhSuccess(null), + ); + + const result = await ovhClient.upsertRecord(cfg, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "5.6.7.8", + }); + + expect(result).toEqual({ id: "10" }); + const calls = apiCalls(); + expect(calls[2]?.[1].method).toBe("POST"); + expect(calls[3]?.[0]).toContain("/refresh"); }); it("omits the ttl so OVH applies the zone default", async () => { diff --git a/apps/dokploy/__test__/dns/porkbun.test.ts b/apps/dokploy/__test__/dns/porkbun.test.ts index 2275e9c5f..a72b882ba 100644 --- a/apps/dokploy/__test__/dns/porkbun.test.ts +++ b/apps/dokploy/__test__/dns/porkbun.test.ts @@ -121,9 +121,11 @@ describe("porkbunClient.upsertRecord", () => { expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/"); }); - it("edits the existing record instead of creating a duplicate", async () => { + it("edits the existing record when content matches", async () => { mockFetch - .mockResolvedValueOnce(pbSuccess({ records: [{ id: "existing-1" }] })) + .mockResolvedValueOnce( + pbSuccess({ records: [{ id: "existing-1", content: "5.6.7.8" }] }), + ) .mockResolvedValueOnce(pbSuccess({})); const result = await porkbunClient.upsertRecord(config, { @@ -138,6 +140,30 @@ describe("porkbunClient.upsertRecord", () => { expect(editUrl).toContain("/dns/edit/example.com/existing-1"); }); + it("creates a new record when content differs from existing", async () => { + mockFetch + .mockResolvedValueOnce( + pbSuccess({ records: [{ id: "existing-1", content: "1.1.1.1" }] }), + ) + .mockResolvedValueOnce(pbSuccess({ id: "new-2" })); + + const result = await porkbunClient.upsertRecord(config, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "5.6.7.8", + }); + + expect(result).toEqual({ id: "new-2" }); + const [createUrl, createInit] = mockFetch.mock.calls[1] as [ + string, + RequestInit, + ]; + expect(createUrl).toContain("/dns/create/example.com"); + const body = JSON.parse(createInit.body as string); + expect(body).toMatchObject({ name: "app", type: "A", content: "5.6.7.8" }); + }); + it("defaults ttl to 600 when not provided", async () => { mockFetch .mockResolvedValueOnce(pbSuccess({ records: [] })) diff --git a/packages/server/src/utils/dns/cloudflare.ts b/packages/server/src/utils/dns/cloudflare.ts index 24cb9c196..85c53715f 100644 --- a/packages/server/src/utils/dns/cloudflare.ts +++ b/packages/server/src/utils/dns/cloudflare.ts @@ -168,12 +168,16 @@ export const cloudflareClient: DnsClient = { ttl: record.ttl ?? 1, }; - const existing = await cfFetch<{ id: string }[]>( + const existing = await cfFetch< + { id: string; type: string; content: string; priority?: number }[] + >( config, `/zones/${record.zoneId}/dns_records?type=${record.type}&name=${encodeURIComponent(record.name)}`, ); - const existingRecord = existing[0]; + const existingRecord = existing.find( + (r) => inlinePriority(r) === record.content, + ); if (existingRecord) { const updated = await cfFetch<{ id: string }>( config, diff --git a/packages/server/src/utils/dns/infomaniak.ts b/packages/server/src/utils/dns/infomaniak.ts index 56c0a8231..0ddcee26e 100644 --- a/packages/server/src/utils/dns/infomaniak.ts +++ b/packages/server/src/utils/dns/infomaniak.ts @@ -175,7 +175,8 @@ export const infomaniakClient: DnsClient = { const match = existing.find( (candidate) => candidate.type === record.type && - normalizeSource(candidate.source) === source, + normalizeSource(candidate.source) === source && + unquoteTarget(candidate.target) === record.content, ); const body = JSON.stringify(recordPayload(record, record.zoneId)); diff --git a/packages/server/src/utils/dns/ovh.ts b/packages/server/src/utils/dns/ovh.ts index 1e0d0a075..67fbd6ca6 100644 --- a/packages/server/src/utils/dns/ovh.ts +++ b/packages/server/src/utils/dns/ovh.ts @@ -285,7 +285,18 @@ export const ovhClient: DnsClient = { )}&subDomain=${encodeURIComponent(subDomain)}`, ); - const existingId = existing[0]; + let existingId: number | undefined; + for (const id of existing) { + const candidate = await ovhFetch( + config, + `/domain/zone/${zone}/record/${id}`, + ); + if (candidate.target === record.content) { + existingId = id; + break; + } + } + if (existingId !== undefined) { await ovhFetch(config, `/domain/zone/${zone}/record/${existingId}`, { method: "PUT", diff --git a/packages/server/src/utils/dns/porkbun.ts b/packages/server/src/utils/dns/porkbun.ts index 09133fe38..0d1cb3ef5 100644 --- a/packages/server/src/utils/dns/porkbun.ts +++ b/packages/server/src/utils/dns/porkbun.ts @@ -96,7 +96,9 @@ export const porkbunClient: DnsClient = { ttl: record.ttl ?? 600, }; - const existingRecord = existing.records[0]; + const existingRecord = existing.records.find( + (r) => r.content === record.content, + ); if (existingRecord) { await pbFetch( config,