fix(dns): represent the Infomaniak apex as a root dot

Verified against a live Infomaniak account: the API returns `source: "."` for
apex records, not "" as assumed.

Reading them back produced a doubled dot ("..example.com"), and writing "" meant
an apex upsert never matched the existing record, so it would have created a
duplicate apex record instead of updating it.

toSource now emits "." for the apex and toFqdn accepts ".", "" and "@" so a
hand-written record still round-trips.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guillaume Juge 2026-09-01 15:58:31 +02:00
parent ff3ebefc70
commit 2bc22c520d
2 changed files with 42 additions and 10 deletions

View File

@ -100,11 +100,9 @@ describe("infomaniakClient.listRecords", () => {
);
});
it("treats an @ source as the apex", async () => {
it.each([".", "", "@"])("treats a %s source as the apex", async (source) => {
mockFetch.mockResolvedValue(
ikSuccess([
{ id: 12, type: "A", source: "@", target: "1.2.3.4", ttl: 300 },
]),
ikSuccess([{ id: 12, type: "A", source, target: "1.2.3.4", ttl: 300 }]),
);
const records = await infomaniakClient.listRecords(config, "example.com");
@ -200,7 +198,7 @@ describe("infomaniakClient.upsertRecord", () => {
expect(lastBody().ttl).toBe(300);
});
it("writes an empty source for an apex record and strips the trailing dot", async () => {
it("writes a root dot as the source for an apex record and strips the trailing dot", async () => {
mockFetch
.mockResolvedValueOnce(ikSuccess([]))
.mockResolvedValueOnce(ikSuccess({ id: 43 }));
@ -212,7 +210,38 @@ describe("infomaniakClient.upsertRecord", () => {
content: "1.2.3.4",
});
expect(lastBody().source).toBe("");
expect(lastBody().source).toBe(".");
});
it("matches the existing apex record instead of creating a duplicate", async () => {
mockFetch
.mockResolvedValueOnce(
ikSuccess([
{
id: 8,
type: "TXT",
source: ".",
target: '"v=spf1 -all"',
ttl: 3600,
},
]),
)
.mockResolvedValueOnce(ikSuccess({ id: 8 }));
const result = await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "TXT",
name: "example.com",
content: "v=spf1 -all",
});
expect(result).toEqual({ id: "8" });
const [url, init] = lastCall();
expect(init.method).toBe("PUT");
expect(url).toBe(
"https://api.infomaniak.com/2/zones/example.com/records/8",
);
expect(lastBody().target).toBe('"v=spf1 -all"');
});
it("quotes a TXT target on write", async () => {

View File

@ -54,19 +54,22 @@ const ikFetch = async <T>(
return body.data as T;
};
// Infomaniak's "source" holds the subdomain only, relative to the zone, and is
// empty for the apex.
// 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.
const APEX_SOURCES = new Set(["", ".", "@"]);
const toSource = (name: string, zone: string) => {
const fqdn = name.replace(/\.$/, "");
if (fqdn === zone) {
return "";
return ".";
}
const suffix = `.${zone}`;
return fqdn.endsWith(suffix) ? fqdn.slice(0, -suffix.length) : fqdn;
};
const toFqdn = (source: string, zone: string) =>
source === "" || source === "@" ? zone : `${source}.${zone}`;
APEX_SOURCES.has(source) ? zone : `${source}.${zone}`;
// 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.