From 39c09f4b03297144f77388cff243b5c86a65ffba Mon Sep 17 00:00:00 2001 From: beatz174-bit Date: Fri, 24 Apr 2026 17:47:06 +1000 Subject: [PATCH 1/2] Add live-gated DNS record acceptance edge-case tests --- internal/provider/provider_acc_test.go | 142 +++++++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/internal/provider/provider_acc_test.go b/internal/provider/provider_acc_test.go index df3397e..7086caa 100644 --- a/internal/provider/provider_acc_test.go +++ b/internal/provider/provider_acc_test.go @@ -2,8 +2,13 @@ package provider import ( "context" + "errors" + "fmt" + "math/rand" "os" + "strings" "testing" + "time" "github.com/dynu/terraform-provider-dynu/internal/dynuclient" ) @@ -75,3 +80,140 @@ func TestAccDataSourceDNSRecords(t *testing.T) { t.Fatal("expected records slice, got nil") } } + +func TestAccDNSRecordAWithEmptyContent(t *testing.T) { + testAccPreCheck(t) + hostname := testAccDomainFromEnv(t) + domainID, _, client := testAccDomainClient(t, hostname) + + nodeName := testAccDisposableNodeName("acc-empty-a") + record := testAccCreateRecordMaybeSkipUnsupported( + t, + client, + domainID, + dynuclient.CreateDNSRecordRequest{ + NodeName: nodeName, + RecordType: "A", + }, + "A record with empty content", + ) + defer testAccDeleteRecord(t, client, domainID, record.ID) + + got := testAccFetchRecordFromList(t, client, domainID, record.ID) + if strings.TrimSpace(got.Content) != "" { + t.Fatalf("expected Dynu to return empty content for A record, got %q", got.Content) + } +} + +func TestAccDNSRecordAAAAWithEmptyContent(t *testing.T) { + testAccPreCheck(t) + hostname := testAccDomainFromEnv(t) + domainID, _, client := testAccDomainClient(t, hostname) + + nodeName := testAccDisposableNodeName("acc-empty-aaaa") + record := testAccCreateRecordMaybeSkipUnsupported( + t, + client, + domainID, + dynuclient.CreateDNSRecordRequest{ + NodeName: nodeName, + RecordType: "AAAA", + }, + "AAAA record with empty content", + ) + defer testAccDeleteRecord(t, client, domainID, record.ID) + + got := testAccFetchRecordFromList(t, client, domainID, record.ID) + if strings.TrimSpace(got.Content) != "" { + t.Fatalf("expected Dynu to return empty content for AAAA record, got %q", got.Content) + } +} + +func TestAccDNSRecordCNAMELifecycle(t *testing.T) { + testAccPreCheck(t) + hostname := testAccDomainFromEnv(t) + domainID, _, client := testAccDomainClient(t, hostname) + + nodeName := testAccDisposableNodeName("acc-cname") + + created, err := client.CreateDNSRecord(context.Background(), domainID, dynuclient.CreateDNSRecordRequest{ + NodeName: nodeName, + RecordType: "CNAME", + Content: "target1.example.com", + TTL: 120, + }) + if err != nil { + t.Fatalf("CreateDNSRecord() failed for CNAME create: %v", err) + } + defer testAccDeleteRecord(t, client, domainID, created.ID) + + updated, err := client.UpdateDNSRecord(context.Background(), domainID, created.ID, dynuclient.UpdateDNSRecordRequest{ + NodeName: nodeName, + RecordType: "CNAME", + Content: "target2.example.com", + TTL: 300, + }) + if err != nil { + t.Fatalf("UpdateDNSRecord() failed for CNAME update: %v", err) + } + if got := strings.TrimSuffix(strings.ToLower(updated.Content), "."); got != "target2.example.com" { + t.Fatalf("unexpected updated content from create/update response: %q", updated.Content) + } + + read := testAccFetchRecordFromList(t, client, domainID, created.ID) + if got := strings.TrimSuffix(strings.ToLower(read.Content), "."); got != "target2.example.com" { + t.Fatalf("expected ListDNSRecords() read-back to reflect updated CNAME target, got %q", read.Content) + } +} + +func testAccDomainClient(t *testing.T, hostname string) (int64, string, *dynuclient.Client) { + t.Helper() + client := dynuclient.New(os.Getenv("DYNU_API_KEY")) + domainID, domainName, err := client.GetRootDomain(context.Background(), hostname) + if err != nil { + t.Fatalf("GetRootDomain() failed: %v", err) + } + return domainID, domainName, client +} + +func testAccDisposableNodeName(prefix string) string { + return fmt.Sprintf("%s-%d-%04d", prefix, time.Now().UnixNano(), rand.Intn(10000)) +} + +func testAccCreateRecordMaybeSkipUnsupported(t *testing.T, client *dynuclient.Client, domainID int64, req dynuclient.CreateDNSRecordRequest, scenario string) *dynuclient.DNSRecord { + t.Helper() + record, err := client.CreateDNSRecord(context.Background(), domainID, req) + if err == nil { + return record + } + + var apiErr *dynuclient.APIError + if errors.As(err, &apiErr) && apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 { + t.Skipf("Dynu account/API does not support %s in this environment (%v)", scenario, err) + } + + t.Fatalf("CreateDNSRecord() failed for %s: %v", scenario, err) + return nil +} + +func testAccFetchRecordFromList(t *testing.T, client *dynuclient.Client, domainID int64, recordID int64) dynuclient.DNSRecord { + t.Helper() + records, err := client.ListDNSRecords(context.Background(), domainID) + if err != nil { + t.Fatalf("ListDNSRecords() failed: %v", err) + } + for _, record := range records { + if record.ID == recordID { + return record + } + } + t.Fatalf("record id %d not found in ListDNSRecords() response", recordID) + return dynuclient.DNSRecord{} +} + +func testAccDeleteRecord(t *testing.T, client *dynuclient.Client, domainID int64, recordID int64) { + t.Helper() + if err := client.DeleteDNSRecord(context.Background(), domainID, recordID); err != nil { + t.Fatalf("DeleteDNSRecord() cleanup failed: %v", err) + } +} From 8a3b5491884aac052de6eee0f1b3b85dcbfdf88e Mon Sep 17 00:00:00 2001 From: beatz174-bit Date: Fri, 24 Apr 2026 18:01:39 +1000 Subject: [PATCH 2/2] Tighten empty-content acceptance skip conditions --- internal/provider/provider_acc_test.go | 84 +++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 1 deletion(-) diff --git a/internal/provider/provider_acc_test.go b/internal/provider/provider_acc_test.go index 7086caa..5443b7b 100644 --- a/internal/provider/provider_acc_test.go +++ b/internal/provider/provider_acc_test.go @@ -188,7 +188,7 @@ func testAccCreateRecordMaybeSkipUnsupported(t *testing.T, client *dynuclient.Cl } var apiErr *dynuclient.APIError - if errors.As(err, &apiErr) && apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 { + if errors.As(err, &apiErr) && isUnsupportedEmptyContentAPIError(apiErr) { t.Skipf("Dynu account/API does not support %s in this environment (%v)", scenario, err) } @@ -217,3 +217,85 @@ func testAccDeleteRecord(t *testing.T, client *dynuclient.Client, domainID int64 t.Fatalf("DeleteDNSRecord() cleanup failed: %v", err) } } + +func isUnsupportedEmptyContentAPIError(apiErr *dynuclient.APIError) bool { + if apiErr == nil || apiErr.StatusCode != 400 { + return false + } + + normalizedType := strings.ToLower(strings.TrimSpace(apiErr.Type)) + if normalizedType != "validation exception" { + return false + } + + normalizedMessage := strings.ToLower(strings.TrimSpace(apiErr.Message)) + knownUnsupportedMessages := []string{ + "content is required", + "ipv4address is required", + "ipv6address is required", + } + for _, fragment := range knownUnsupportedMessages { + if strings.Contains(normalizedMessage, fragment) { + return true + } + } + + return false +} + +func TestIsUnsupportedEmptyContentAPIError(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + err *dynuclient.APIError + expect bool + }{ + { + name: "validation message with required content", + err: &dynuclient.APIError{ + StatusCode: 400, + Type: "Validation Exception", + Message: "Content is required.", + }, + expect: true, + }, + { + name: "validation message with ipv4 required", + err: &dynuclient.APIError{ + StatusCode: 400, + Type: "Validation Exception", + Message: "IPv4Address is required for A records.", + }, + expect: true, + }, + { + name: "different validation error should fail", + err: &dynuclient.APIError{ + StatusCode: 400, + Type: "Validation Exception", + Message: "recordType is invalid", + }, + expect: false, + }, + { + name: "transient throttling should fail", + err: &dynuclient.APIError{ + StatusCode: 429, + Type: "Too Many Requests", + Message: "rate limit exceeded", + }, + expect: false, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := isUnsupportedEmptyContentAPIError(tc.err); got != tc.expect { + t.Fatalf("unexpected result for %q: got %v, want %v", tc.name, got, tc.expect) + } + }) + } +}