From 349e002a42f02be6798f7376e3b404432dd1f904 Mon Sep 17 00:00:00 2001 From: bindreams <28830446+bindreams@users.noreply.github.com> Date: Sat, 6 Jun 2026 19:36:37 +0200 Subject: [PATCH 1/5] Encode and decode TXT records as RFC 1035 zone presentation --- models.go | 11 +-- txt.go | 136 ++++++++++++++++++++++++++++++++++++ txt_test.go | 193 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 335 insertions(+), 5 deletions(-) create mode 100644 txt.go create mode 100644 txt_test.go diff --git a/models.go b/models.go index 23209e9..03c91fb 100644 --- a/models.go +++ b/models.go @@ -198,12 +198,14 @@ func (r cfDNSRecord) libdnsRecord(zone string) (libdns.Record, error) { } return rr.Parse() case "TXT": - // unwrap the quotes from the content - unwrappedContent := unwrapContent(r.Content) + text, err := decodeTXT(r.Content) + if err != nil { + return libdns.TXT{}, fmt.Errorf("decoding TXT content %q: %v", r.Content, err) + } return libdns.TXT{ Name: name, TTL: ttl, - Text: unwrappedContent, + Text: text, }, nil // NOTE: HTTPS records from Cloudflare have a `r.Content` that can be // parsed by [libdns.RR.Parse] so that is what we do here. While we are @@ -294,8 +296,7 @@ func cloudflareRecord(r libdns.Record) (cfDNSRecord, error) { cfRec.Proxied = true } if rr.Type == "TXT" { - // wrap the content in quotes - cfRec.Content = wrapContent(cfRec.Content) + cfRec.Content = encodeTXT(rr.Data) } return cfRec, nil } diff --git a/txt.go b/txt.go new file mode 100644 index 0000000..4e07f45 --- /dev/null +++ b/txt.go @@ -0,0 +1,136 @@ +package cloudflare + +import ( + "fmt" + "strings" +) + +// Cloudflare stores and returns TXT record content in RFC 1035 zone-file +// presentation: one or more "character-strings" of at most 255 octets each, +// each wrapped in double quotes and separated by spaces. Within a quoted +// string, '"' and '\' are backslash-escaped and any other non-printable or +// high-bit octet is written as a three-digit decimal escape (\DDD). libdns, by +// contrast, models the whole TXT value as a single arbitrary-length, +// arbitrary-byte string (see libdns.TXT.Text). encodeTXT and decodeTXT +// translate between the two. +// +// Cloudflare accepts and faithfully round-trips every octet 0x00-0xFF through +// the \DDD form, up to its 4096 wire-format-byte limit. Some bytes (embedded +// NUL especially) may not survive every DNS resolver, but that is a transport +// concern, not a storage one, so the codec does not police byte values. + +const txtMaxChunk = 255 + +// encodeTXT converts a raw TXT value into Cloudflare's content field. +func encodeTXT(text string) string { + // An empty value encodes to "" (a single empty character-string). + // Cloudflare rejects that with a clear error, which we surface rather than + // silently swallow. + if len(text) == 0 { + return `""` + } + + var sb strings.Builder + sep := "" + for len(text) > 0 { + sb.WriteString(sep) + sep = " " + + n := txtMaxChunk + if n > len(text) { + n = len(text) + } + chunk := text[:n] + text = text[n:] + + sb.WriteByte('"') + for j := 0; j < len(chunk); j++ { + b := chunk[j] + switch { + case b == '"': + sb.WriteString(`\"`) + case b == '\\': + sb.WriteString(`\\`) + case b >= 0x20 && b <= 0x7E: + sb.WriteByte(b) + default: + fmt.Fprintf(&sb, `\%03d`, b) + } + } + sb.WriteByte('"') + } + return sb.String() +} + +// decodeTXT converts Cloudflare's TXT content field back into the raw value. +// +// Quoted content is parsed as one or more space-separated character-strings; +// the decoded octets of every segment are concatenated (libdns's "one long +// string" model). Unquoted content is returned verbatim -- Cloudflare accepts +// and returns it literally and does NOT interpret escapes in that form. +// +// An error is returned only for genuinely malformed presentation; Cloudflare's +// own output never triggers it, so the errors guard against corrupt data from +// elsewhere rather than normal operation. +func decodeTXT(content string) (string, error) { + if !strings.HasPrefix(content, `"`) { + return content, nil + } + + var out []byte + i := 0 + for i < len(content) { + for i < len(content) && (content[i] == ' ' || content[i] == '\t') { + i++ + } + if i == len(content) { + break + } + if content[i] != '"' { + return "", fmt.Errorf("malformed TXT content: unexpected %q at offset %d", content[i], i) + } + i++ + + closed := false + for i < len(content) { + b := content[i] + if b == '"' { + i++ + closed = true + break + } + if b == '\\' { + if i+1 >= len(content) { + return "", fmt.Errorf("malformed TXT content: trailing backslash") + } + next := content[i+1] + if next >= '0' && next <= '9' { + if i+3 >= len(content) { + return "", fmt.Errorf("malformed TXT content: incomplete decimal escape at offset %d", i) + } + d1, d2 := content[i+2], content[i+3] + if d1 < '0' || d1 > '9' || d2 < '0' || d2 > '9' { + return "", fmt.Errorf("malformed TXT content: invalid decimal escape at offset %d", i) + } + val := int(next-'0')*100 + int(d1-'0')*10 + int(d2-'0') + if val > 255 { + return "", fmt.Errorf("malformed TXT content: decimal escape %d out of range at offset %d", val, i) + } + out = append(out, byte(val)) + i += 4 + } else { + // \ represents that character literally. + out = append(out, next) + i += 2 + } + continue + } + out = append(out, b) + i++ + } + if !closed { + return "", fmt.Errorf("malformed TXT content: unterminated quoted string") + } + } + return string(out), nil +} diff --git a/txt_test.go b/txt_test.go new file mode 100644 index 0000000..710f4c6 --- /dev/null +++ b/txt_test.go @@ -0,0 +1,193 @@ +package cloudflare + +import ( + "strings" + "testing" +) + +func TestEncodeTXT(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"empty", "", `""`}, + {"ascii", "v=spf1 -all", `"v=spf1 -all"`}, + {"dquote", `a"b`, `"a\"b"`}, + {"backslash", `a\b`, `"a\\b"`}, + {"tab", "x\ty", `"x\009y"`}, + {"nul", "x\x00y", `"x\000y"`}, + {"highbit", "x\xffy", `"x\255y"`}, + {"utf8-eacute", "é", `"\195\169"`}, // é = 0xC3 0xA9 + {"semicolon-kept", "v=spf1; -all", `"v=spf1; -all"`}, + {"exactly-255", strings.Repeat("a", 255), `"` + strings.Repeat("a", 255) + `"`}, + {"256-splits", strings.Repeat("a", 256), `"` + strings.Repeat("a", 255) + `" "a"`}, + {"300-splits", strings.Repeat("a", 300), `"` + strings.Repeat("a", 255) + `" "` + strings.Repeat("a", 45) + `"`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := encodeTXT(c.in) + if got != c.want { + t.Fatalf("encodeTXT(%q):\n want %q\n got %q", c.in, c.want, got) + } + }) + } +} + +func TestDecodeTXT(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"ascii", `"hello"`, "hello"}, + {"dquote", `"a\"b"`, `a"b`}, + {"backslash", `"a\\b"`, `a\b`}, + {"decimal-A", `"x\065y"`, "xAy"}, + {"tab", `"x\009y"`, "x\ty"}, + {"utf8-eacute", `"h\195\169llo"`, "héllo"}, + {"two-segments", `"a" "b"`, "ab"}, + {"empty-quoted", `""`, ""}, + {"unquoted-literal", "hello world", "hello world"}, + {"unquoted-escape-not-interpreted", `x\065y`, `x\065y`}, + {"backslash-nondigit", `"a\zb"`, "azb"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := decodeTXT(c.in) + if err != nil { + t.Fatalf("decodeTXT(%q) unexpected error: %v", c.in, err) + } + if got != c.want { + t.Fatalf("decodeTXT(%q):\n want %q\n got %q", c.in, c.want, got) + } + }) + } +} + +func TestDecodeTXTErrors(t *testing.T) { + bad := []struct{ name, in string }{ + {"unterminated", `"abc`}, + {"trailing-backslash", `"abc\`}, + {"short-decimal", `"a\99"`}, // \99 then '"' -- not 3 digits + {"garbage-after-segment", `"a"x`}, + {"decimal-out-of-range", `"\256"`}, + {"incomplete-decimal", `"\09`}, + } + for _, c := range bad { + t.Run(c.name, func(t *testing.T) { + if _, err := decodeTXT(c.in); err == nil { + t.Fatalf("decodeTXT(%q): expected error, got nil", c.in) + } + }) + } +} + +func allBytes() string { + b := make([]byte, 256) + for i := range b { + b[i] = byte(i) + } + return string(b) +} + +func TestTXTRoundTrip(t *testing.T) { + dkim := "v=DKIM1; k=rsa; p=" + strings.Repeat("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA", 9) + "IDAQAB" + payloads := map[string]string{ + "empty": "", + "ascii": "v=spf1 include:example.com -all", + "quote-backslash": `has "quotes" and \back\slash`, + "all-256-bytes": allBytes(), + "dkim-410": dkim, + "utf8": "café ☕ 日本語", + "nul-highbit-span": strings.Repeat("\x00\xff", 200), // 400 bytes across 255 boundary + "len-1000": strings.Repeat("A", 1000), + "len-2048": strings.Repeat("B", 2048), + } + for name, p := range payloads { + t.Run(name, func(t *testing.T) { + got, err := decodeTXT(encodeTXT(p)) + if err != nil { + t.Fatalf("decode(encode(%s)) error: %v", name, err) + } + if got != p { + t.Fatalf("round-trip mismatch for %s (len %d -> %d)", name, len(p), len(got)) + } + }) + } +} + +func TestEncodeTXTChunkCount(t *testing.T) { + cases := []struct { + n int + chunks int + }{ + {0, 1}, {1, 1}, {255, 1}, {256, 2}, {510, 2}, {511, 3}, {1000, 4}, + } + for _, c := range cases { + // Payload is quote-free, so there are no escaped \" and the segment + // count equals (number of " characters) / 2. + got := strings.Count(encodeTXT(strings.Repeat("a", c.n)), `"`) / 2 + if got != c.chunks { + t.Fatalf("encodeTXT(%d bytes): want %d chunks, got %d", c.n, c.chunks, got) + } + } +} + +// countTXTSegments counts the quoted character-strings in encodeTXT output, +// scanning escape sequences so that an escaped quote (\") or backslash (\\) is +// not mistaken for a structural quote, and a literal space inside a segment is +// not mistaken for a segment separator. +func countTXTSegments(content string) int { + n := 0 + for i := 0; i < len(content); { + if content[i] != '"' { + i++ + continue + } + n++ + i++ + for i < len(content) && content[i] != '"' { + if content[i] == '\\' { + i += 2 // skip the escaped byte (\" , \\ , or first digit of \DDD) + } else { + i++ + } + } + i++ + } + return n +} + +// TestEncodeTXTChunkBoundaryByOctet pins that chunking is by RAW octet, not by +// escaped presentation width: a 255-octet chunk is one character-string even +// when its presentation is far longer than 255 chars, and the 256th octet +// starts a new chunk regardless of whether it escapes to 1, 2, or 4 chars. +func TestEncodeTXTChunkBoundaryByOctet(t *testing.T) { + cases := []struct { + name string + in string + segments int + }{ + {"255-dquotes-1-segment", strings.Repeat(`"`, 255), 1}, + {"256-dquotes-2-segments", strings.Repeat(`"`, 256), 2}, + {"255-0xff-1-segment", strings.Repeat("\xff", 255), 1}, + {"254a-dquote-1-segment", strings.Repeat("a", 254) + `"`, 1}, + {"255a-dquote-2-segments", strings.Repeat("a", 255) + `"`, 2}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + enc := encodeTXT(c.in) + if got := countTXTSegments(enc); got != c.segments { + t.Fatalf("segment count: want %d, got %d (presentation len %d)", c.segments, got, len(enc)) + } + dec, err := decodeTXT(enc) + if err != nil { + t.Fatalf("decode: %v", err) + } + if dec != c.in { + t.Fatalf("round-trip mismatch (in %d octets -> out %d)", len(c.in), len(dec)) + } + }) + } +} From a0625c9fe4b454f1be4aaf63165232f42cc39ccb Mon Sep 17 00:00:00 2001 From: bindreams <28830446+bindreams@users.noreply.github.com> Date: Sat, 6 Jun 2026 19:36:37 +0200 Subject: [PATCH 2/5] Match TXT records for deletion by decoded value --- client.go | 64 +++++++++++++------------------- client_test.go | 99 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 38 deletions(-) create mode 100644 client_test.go diff --git a/client.go b/client.go index a226e67..12af7ab 100644 --- a/client.go +++ b/client.go @@ -7,7 +7,6 @@ import ( "fmt" "net/http" "net/url" - "strings" "github.com/libdns/libdns" ) @@ -69,15 +68,12 @@ func (p *Provider) getDNSRecords(ctx context.Context, zoneInfo cfZone, rec libdn qs.Set("type", rr.Type) qs.Set("name", libdns.AbsoluteName(rr.Name, zoneInfo.Name)) - var unwrappedContent string if matchContent { - if rr.Type == "TXT" { - unwrappedContent = unwrapContent(rr.Content) - // Use the contains (wildcard) search with unquoted content to return both quoted and unquoted content - qs.Set("content.contains", unwrappedContent) - } else if rr.Type != "SRV" && rr.Type != "HTTPS" && rr.Type != "SVCB" { - // SRV, HTTPS, SVCB records don't support content.exact filtering in Cloudflare API - // They will be matched by type and name only + // TXT is matched client-side by decoded value (below): Cloudflare + // re-chunks and re-escapes stored TXT presentation, so neither + // content.exact nor content.contains reliably matches our encoding. + // SRV, HTTPS, SVCB don't support content.exact filtering at all. + if rr.Type != "TXT" && rr.Type != "SRV" && rr.Type != "HTTPS" && rr.Type != "SVCB" { qs.Set("content.exact", rr.Content) } } @@ -89,28 +85,34 @@ func (p *Provider) getDNSRecords(ctx context.Context, zoneInfo cfZone, rec libdn } var results []cfDNSRecord - _, err = p.doAPIRequest(req, &results) + if _, err = p.doAPIRequest(req, &results); err != nil { + return nil, err + } - // Since the TXT search used contains (wildcard), check for exact matches + // Match TXT by decoded value, since Cloudflare's stored presentation need + // not equal what we sent. if matchContent && rr.Type == "TXT" { - for i := 0; i < len(results); i++ { - // Prefer exact quoted content - if results[i].Content == rr.Content { - return []cfDNSRecord{results[i]}, nil + want := rec.RR().Data + var matched []cfDNSRecord + for _, cand := range results { + // Per the libdns RecordDeleter contract, an empty value matches any + // value for the given name+type. + if want == "" { + matched = append(matched, cand) + continue } - } - - for i := 0; i < len(results); i++ { - // Using exact unquoted content is acceptable - if results[i].Content == unwrappedContent { - return []cfDNSRecord{results[i]}, nil + got, decErr := decodeTXT(cand.Content) + if decErr != nil { + return nil, fmt.Errorf("decoding TXT content %q: %v", cand.Content, decErr) + } + if got == want { + matched = append(matched, cand) } } - - return []cfDNSRecord{}, nil + return matched, nil } - return results, err + return results, nil } func (p *Provider) getZoneInfo(ctx context.Context, zoneName string) (cfZone, error) { @@ -201,17 +203,3 @@ func (p *Provider) doAPIRequest(req *http.Request, result any) (cfResponse, erro } const baseURL = "https://api.cloudflare.com/client/v4" - -func unwrapContent(content string) string { - if strings.HasPrefix(content, `"`) && strings.HasSuffix(content, `"`) { - content = strings.TrimPrefix(strings.TrimSuffix(content, `"`), `"`) - } - return content -} - -func wrapContent(content string) string { - if !strings.HasPrefix(content, `"`) && !strings.HasSuffix(content, `"`) { - content = fmt.Sprintf("%q", content) - } - return content -} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..c2dee1d --- /dev/null +++ b/client_test.go @@ -0,0 +1,99 @@ +package cloudflare + +import ( + "bytes" + "context" + "io" + "net/http" + "testing" + + "github.com/libdns/libdns" +) + +// fakeClient returns a fixed response body for every request, so getDNSRecords' +// TXT matching can be exercised deterministically without network or creds. +type fakeClient struct{ body string } + +func (f fakeClient) Do(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(f.body)), + Header: make(http.Header), + }, nil +} + +func TestGetDNSRecordsTXTMatching(t *testing.T) { + // Two TXT records at the same name with distinct decoded values. + const body = `{"success":true,"result":[` + + `{"id":"a","type":"TXT","name":"foo.example.com","content":"\"hello\""},` + + `{"id":"b","type":"TXT","name":"foo.example.com","content":"\"world\""}` + + `]}` + p := &Provider{APIToken: "test", HTTPClient: fakeClient{body: body}} + zone := cfZone{ID: "zone123", Name: "example.com"} + ctx := context.Background() + + cases := []struct { + name string + text string + wantIDs []string + }{ + {"specific-match", "hello", []string{"a"}}, + {"other-specific-match", "world", []string{"b"}}, + {"empty-matches-all", "", []string{"a", "b"}}, // libdns empty-value contract + {"no-match", "nope", nil}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := p.getDNSRecords(ctx, zone, libdns.TXT{Name: "foo", Text: c.text}, true) + if err != nil { + t.Fatalf("getDNSRecords: %v", err) + } + var ids []string + for _, r := range got { + ids = append(ids, r.ID) + } + if !equalStrings(ids, c.wantIDs) { + t.Fatalf("text=%q: want IDs %v, got %v", c.text, c.wantIDs, ids) + } + }) + } +} + +func TestGetDNSRecordsTXTMatchingMalformed(t *testing.T) { + // A candidate whose stored content cannot be decoded (here, an unterminated + // quoted string) must surface an error rather than be silently skipped. + const body = `{"success":true,"result":[` + + `{"id":"x","type":"TXT","name":"foo.example.com","content":"\"abc"}` + + `]}` + p := &Provider{APIToken: "test", HTTPClient: fakeClient{body: body}} + zone := cfZone{ID: "zone123", Name: "example.com"} + ctx := context.Background() + + // Non-empty value forces a decode, which must fail loudly. + if _, err := p.getDNSRecords(ctx, zone, libdns.TXT{Name: "foo", Text: "abc"}, true); err == nil { + t.Fatal("expected error for malformed TXT content, got nil") + } + + // Empty value ("match any") never decodes, so it still matches the + // undecodable record without error. + got, err := p.getDNSRecords(ctx, zone, libdns.TXT{Name: "foo"}, true) + if err != nil { + t.Fatalf("empty-value match should not decode: %v", err) + } + if len(got) != 1 { + t.Fatalf("empty-value match: want 1, got %d", len(got)) + } +} + +func equalStrings(a, b []string) bool { + // FIXME: replace with slices.Equal in go >= 1.21. + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} From d69f2ee96e0a54d5893ee046951f23a1d56ee30d Mon Sep 17 00:00:00 2001 From: bindreams <28830446+bindreams@users.noreply.github.com> Date: Sat, 6 Jun 2026 19:36:37 +0200 Subject: [PATCH 3/5] Fix nil-deref and divide-by-zero in GetRecords pagination --- provider.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/provider.go b/provider.go index 8ad8b91..06eca67 100644 --- a/provider.go +++ b/provider.go @@ -60,8 +60,12 @@ func (p *Provider) GetRecords(ctx context.Context, zone string) ([]libdns.Record allRecords = append(allRecords, pageRecords...) + // Guard a nil ResultInfo and a zero PerPage before the division below. + if response.ResultInfo == nil || response.ResultInfo.PerPage == 0 || len(pageRecords) == 0 { + break + } lastPage := (response.ResultInfo.TotalCount + response.ResultInfo.PerPage - 1) / response.ResultInfo.PerPage - if response.ResultInfo == nil || page >= lastPage || len(pageRecords) == 0 { + if page >= lastPage { break } From 74320d9521d0c7716f29d69411621bd2e9faea22 Mon Sep 17 00:00:00 2001 From: bindreams <28830446+bindreams@users.noreply.github.com> Date: Sat, 6 Jun 2026 19:36:37 +0200 Subject: [PATCH 4/5] Add live integration tests for long and non-ASCII TXT --- libdnstest/txt_roundtrip_test.go | 189 +++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 libdnstest/txt_roundtrip_test.go diff --git a/libdnstest/txt_roundtrip_test.go b/libdnstest/txt_roundtrip_test.go new file mode 100644 index 0000000..0496b08 --- /dev/null +++ b/libdnstest/txt_roundtrip_test.go @@ -0,0 +1,189 @@ +package main + +import ( + "context" + "os" + "strings" + "testing" + "time" + + "github.com/libdns/cloudflare" + "github.com/libdns/libdns" +) + +// TestLongTXTRoundTrip exercises >255-byte, special-character, and raw-byte TXT +// values end-to-end against the live Cloudflare API: each must round-trip +// byte-exactly through Append -> Get and be removed by Delete. +func TestLongTXTRoundTrip(t *testing.T) { + apiToken, testZone := txtTestEnv(t) + provider := &cloudflare.Provider{APIToken: apiToken} + ctx := context.Background() + + dkim := "v=DKIM1; k=rsa; p=" + strings.Repeat("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA", 9) + "IDAQAB" + cases := []struct{ name, text string }{ + {"test-txt-dkim._domainkey", dkim}, + {"test-txt-special", `has "quotes" and \back\slash`}, + {"test-txt-rawbytes", "\x00\x01\xfe\xff raw \x80\x7f bytes"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := libdns.TXT{Name: tc.name, TTL: 5 * time.Minute, Text: tc.text} + + _, _ = provider.DeleteRecords(ctx, testZone, []libdns.Record{rec}) + t.Cleanup(func() { + _, _ = provider.DeleteRecords(ctx, testZone, []libdns.Record{rec}) + }) + + if _, err := provider.AppendRecords(ctx, testZone, []libdns.Record{rec}); err != nil { + t.Fatalf("AppendRecords: %v", err) + } + + found, err := findTXT(ctx, provider, testZone, tc.name) + if err != nil { + t.Fatalf("GetRecords: %v", err) + } + if found == nil { + t.Fatalf("record %q not found after append", tc.name) + } + if found.Text != tc.text { + t.Fatalf("round-trip mismatch for %q:\n want %q\n got %q", tc.name, tc.text, found.Text) + } + + deleted, err := provider.DeleteRecords(ctx, testZone, []libdns.Record{rec}) + if err != nil { + t.Fatalf("DeleteRecords: %v", err) + } + if len(deleted) != 1 { + t.Fatalf("expected 1 deleted, got %d", len(deleted)) + } + + stillThere, err := findTXT(ctx, provider, testZone, tc.name) + if err != nil { + t.Fatalf("GetRecords after delete: %v", err) + } + if stillThere != nil { + t.Fatalf("record %q still present after delete", tc.name) + } + }) + } +} + +// TestDeleteTXTByEmptyText verifies the libdns RecordDeleter contract: a TXT +// record with an empty Text must delete any TXT at that name regardless of its +// value. +func TestDeleteTXTByEmptyText(t *testing.T) { + apiToken, testZone := txtTestEnv(t) + provider := &cloudflare.Provider{APIToken: apiToken} + ctx := context.Background() + + const name = "test-txt-emptydel" + full := libdns.TXT{Name: name, TTL: 5 * time.Minute, Text: "v=spf1 include:example.net -all"} + + // Clean up by the real value, not empty Text, so teardown doesn't depend on + // the behavior under test. + _, _ = provider.DeleteRecords(ctx, testZone, []libdns.Record{full}) + t.Cleanup(func() { + _, _ = provider.DeleteRecords(ctx, testZone, []libdns.Record{full}) + }) + + if _, err := provider.AppendRecords(ctx, testZone, []libdns.Record{full}); err != nil { + t.Fatalf("AppendRecords: %v", err) + } + + deleted, err := provider.DeleteRecords(ctx, testZone, []libdns.Record{libdns.TXT{Name: name}}) + if err != nil { + t.Fatalf("DeleteRecords(empty Text): %v", err) + } + if len(deleted) != 1 { + t.Fatalf("empty-Text delete: expected 1 deleted, got %d", len(deleted)) + } + + found, err := findTXT(ctx, provider, testZone, name) + if err != nil { + t.Fatalf("GetRecords after delete: %v", err) + } + if found != nil { + t.Fatalf("record %q still present after empty-Text delete", name) + } +} + +// TestSetTXTRoundTrip confirms SetRecords creates and then updates a single +// long TXT record in place with byte-exact round-trip (the DKIM-rotation path). +func TestSetTXTRoundTrip(t *testing.T) { + apiToken, testZone := txtTestEnv(t) + provider := &cloudflare.Provider{APIToken: apiToken} + ctx := context.Background() + + const name = "test-txt-set._domainkey" + v1 := "v=DKIM1; k=rsa; p=" + strings.Repeat("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA", 9) + "IDAQAB" + v2 := "v=DKIM1; k=rsa; p=" + strings.Repeat("ZZZBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA", 7) + "IDAQAB" + + t.Cleanup(func() { + _, _ = provider.DeleteRecords(ctx, testZone, []libdns.Record{libdns.TXT{Name: name, Text: v1}}) + _, _ = provider.DeleteRecords(ctx, testZone, []libdns.Record{libdns.TXT{Name: name, Text: v2}}) + }) + + if _, err := provider.SetRecords(ctx, testZone, []libdns.Record{libdns.TXT{Name: name, TTL: 5 * time.Minute, Text: v1}}); err != nil { + t.Fatalf("SetRecords (create): %v", err) + } + if got, err := findTXT(ctx, provider, testZone, name); err != nil { + t.Fatalf("GetRecords after create: %v", err) + } else if got == nil || got.Text != v1 { + t.Fatalf("after create: want %q, got %+v", v1, got) + } + + if _, err := provider.SetRecords(ctx, testZone, []libdns.Record{libdns.TXT{Name: name, TTL: 5 * time.Minute, Text: v2}}); err != nil { + t.Fatalf("SetRecords (update): %v", err) + } + if got, err := findTXT(ctx, provider, testZone, name); err != nil { + t.Fatalf("GetRecords after update: %v", err) + } else if got == nil || got.Text != v2 { + t.Fatalf("after update: want %q, got %+v", v2, got) + } + // The update must be in place, not a second record. + if n := countTXT(ctx, t, provider, testZone, name); n != 1 { + t.Fatalf("after update: expected exactly 1 TXT at %q, got %d", name, n) + } +} + +func countTXT(ctx context.Context, t *testing.T, p *cloudflare.Provider, zone, name string) int { + t.Helper() + recs, err := p.GetRecords(ctx, zone) + if err != nil { + t.Fatalf("GetRecords: %v", err) + } + n := 0 + for _, r := range recs { + if txt, ok := r.(libdns.TXT); ok && txt.Name == name { + n++ + } + } + return n +} + +func txtTestEnv(t *testing.T) (apiToken, testZone string) { + t.Helper() + apiToken = os.Getenv("CLOUDFLARE_API_TOKEN") + testZone = os.Getenv("CLOUDFLARE_TEST_ZONE") + if apiToken == "" || testZone == "" { + t.Skip("Skipping live TXT test: set CLOUDFLARE_API_TOKEN and CLOUDFLARE_TEST_ZONE") + } + if !strings.HasSuffix(testZone, ".") { + t.Fatal("CLOUDFLARE_TEST_ZONE must have a trailing dot") + } + return apiToken, testZone +} + +func findTXT(ctx context.Context, p *cloudflare.Provider, zone, name string) (*libdns.TXT, error) { + recs, err := p.GetRecords(ctx, zone) + if err != nil { + return nil, err + } + for _, r := range recs { + if txt, ok := r.(libdns.TXT); ok && txt.Name == name { + return &txt, nil + } + } + return nil, nil +} From 3334c55cc1f05d3ceba936731d77603b6c3ae3d6 Mon Sep 17 00:00:00 2001 From: bindreams <28830446+bindreams@users.noreply.github.com> Date: Sat, 6 Jun 2026 19:36:37 +0200 Subject: [PATCH 5/5] Run root-module unit tests in CI --- .github/workflows/go.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 91e53eb..3c38670 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -17,11 +17,13 @@ jobs: with: go-version: "1.23" - - name: Test + - name: Unit tests (root module) + run: go test -v ./... + + - name: Integration tests (libdnstest) env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_TEST_ZONE: ${{ secrets.CLOUDFLARE_TEST_ZONE }} - run: | cd libdnstest/ go test -v ./...