From ed82eb22d8878bfb09bb8a3138275278cb599d52 Mon Sep 17 00:00:00 2001 From: godsonten Date: Fri, 16 Oct 2026 22:33:24 +0100 Subject: [PATCH 1/5] fix: scope DNS mutation output to affected records Refs zsoftly/iaas#577 Signed-off-by: godsonten --- CHANGELOG.md | 4 + docs/commands.md | 4 + internal/commands/commands_test.go | 2 +- internal/commands/dns.go | 241 ++++++++-- internal/commands/dns_record_output_test.go | 472 ++++++++++++++++++++ 5 files changed, 697 insertions(+), 26 deletions(-) create mode 100644 internal/commands/dns_record_output_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 9af7820..edc9bdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), using - **Block-storage snapshot schedules.** `zcp snapshot schedule` manages policies for a scoped volume. It supports creation, listing, reading, updates, pausing, resuming, immediate runs, and deletion. The CLI rejects VM policies and policies outside the selected region and project before writing. +### Fixed + +- `dns record-create` prints one row for the value it created, and `dns record-delete` prints rows for the values removed from the selected record set in table, JSON, and YAML output. Both commands support `--show-zone` to print the resulting zone instead. MX output includes its preference, including `0`; `dns show` remains the full-zone read command. + ## [v1.0.1] - 2026-10-11 ### Fixed diff --git a/docs/commands.md b/docs/commands.md index 449f3f4..9b12979 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -439,9 +439,13 @@ zcp dns create --name example.com --project default-9 # Create a record zcp dns record-create --domain --name www --type A --content 192.0.2.1 zcp dns record-create --domain --name @ --type MX --content mail.example.com. --priority 10 --ttl 3600 +# Record creation and deletion show only the affected values; use --show-zone +# to print every record in the resulting zone. +zcp dns record-create --domain --name www --type A --content 192.0.2.1 --show-zone # Delete a record set (records are addressed by name and type) or a domain zcp dns record-delete --domain --name www --type A +zcp dns record-delete --domain --name www --type A --show-zone zcp dns delete ``` diff --git a/internal/commands/commands_test.go b/internal/commands/commands_test.go index 73b92fd..4ea36c3 100644 --- a/internal/commands/commands_test.go +++ b/internal/commands/commands_test.go @@ -1806,7 +1806,7 @@ func TestDNSRecordCreateMXSendsPriorityEndToEnd(t *testing.T) { if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/records") { json.NewDecoder(r.Body).Decode(&gotBody) w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"status":"Success","message":"Created","data":{"id":"1","name":"example.com","slug":"example-com-1"}}`) + fmt.Fprint(w, `{"status":"Success","message":"Created","data":{"id":"1","name":"example.com","slug":"example-com-1","records":[{"name":"example.com.","type":"MX","ttl":14400,"contents":["10 mail.example.com."]}]}}`) return } w.WriteHeader(http.StatusNotFound) diff --git a/internal/commands/dns.go b/internal/commands/dns.go index 3067b36..a3de8b0 100644 --- a/internal/commands/dns.go +++ b/internal/commands/dns.go @@ -4,12 +4,14 @@ import ( "bufio" "context" "fmt" + "net/netip" "os" "strconv" "strings" "time" "github.com/spf13/cobra" + "github.com/zsoftly/zcp-cli/internal/output" "github.com/zsoftly/zcp-cli/pkg/api/apierrors" "github.com/zsoftly/zcp-cli/pkg/api/dns" ) @@ -293,6 +295,7 @@ func runDNSDelete(cmd *cobra.Command, slug string, yes bool) error { func newDNSRecordCreateCmd() *cobra.Command { var domain, name, recordType, content string var ttl, priority int + var showZone bool cmd := &cobra.Command{ Use: "record-create", @@ -345,7 +348,7 @@ func newDNSRecordCreateCmd() *cobra.Command { p := priority req.Priority = &p } - return runDNSRecordCreate(cmd, domain, req) + return runDNSRecordCreate(cmd, domain, req, showZone) }, } cmd.Flags().StringVar(&domain, "domain", "", "Domain slug (required)") @@ -354,10 +357,11 @@ func newDNSRecordCreateCmd() *cobra.Command { cmd.Flags().StringVar(&content, "content", "", "Record content / value (required)") cmd.Flags().IntVar(&ttl, "ttl", 14400, "Time-to-live in seconds (default: 14400)") cmd.Flags().IntVar(&priority, "priority", 0, "Preference for MX records, 0-65535 (required for MX)") + cmd.Flags().BoolVar(&showZone, "show-zone", false, "Show all records in the resulting zone") return cmd } -func runDNSRecordCreate(cmd *cobra.Command, domainSlug string, req dns.CreateRecordRequest) error { +func runDNSRecordCreate(cmd *cobra.Command, domainSlug string, req dns.CreateRecordRequest, showZone bool) error { _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err @@ -372,29 +376,36 @@ func runDNSRecordCreate(cmd *cobra.Command, domainSlug string, req dns.CreateRec return fmt.Errorf("dns record-create: %w", err) } - // Show the record table for the domain - if len(domain.Records) > 0 { - headers := []string{"NAME", "TYPE", "CONTENT", "TTL"} - rows := make([][]string, 0, len(domain.Records)) - for _, r := range domain.Records { - rows = append(rows, []string{ - r.Name, - r.Type, - r.Content, - strconv.Itoa(r.TTL), - }) + if showZone { + if len(domain.Records) == 0 { + domain, err = svc.Show(ctx, domainSlug) + if err != nil { + return fmt.Errorf("dns record-create: record created but could not verify resulting zone: %w", err) + } } - return printer.PrintTable(headers, rows) + return printDNSRecordRows(printer, domain.Records) } - printer.Fprintf("Record created on domain %q.\n", domainSlug) - return nil + created := findCreatedDNSRecord(domain, req) + if created == nil { + domain, err = svc.Show(ctx, domainSlug) + if err != nil { + return fmt.Errorf("dns record-create: record created but output verification failed: %w", err) + } + created = findCreatedDNSRecord(domain, req) + } + if created == nil { + fmt.Fprintf(os.Stderr, "DNS record created, but its value could not be matched. Use dns show %q to inspect the zone.\n", domainSlug) + return printDNSRecordRows(printer, nil) + } + return printDNSRecordRows(printer, []dns.Record{*created}) } func newDNSRecordDeleteCmd() *cobra.Command { var domain, name, recordType string var recordID int var yes bool + var showZone bool cmd := &cobra.Command{ Use: "record-delete", @@ -408,12 +419,12 @@ func newDNSRecordDeleteCmd() *cobra.Command { if recordID > 0 { // Legacy numeric-ID path; only works on deployments whose DNS // backend exposes record IDs. - return runDNSRecordDelete(cmd, domain, recordID, yes) + return runDNSRecordDelete(cmd, domain, recordID, yes, showZone) } if name == "" || recordType == "" { return fmt.Errorf("--name and --type are required (records are addressed by name and type)") } - return runDNSRecordDeleteByName(cmd, domain, name, recordType, yes) + return runDNSRecordDeleteByName(cmd, domain, name, recordType, yes, showZone) }, } cmd.Flags().StringVar(&domain, "domain", "", "Domain slug (required)") @@ -421,10 +432,11 @@ func newDNSRecordDeleteCmd() *cobra.Command { cmd.Flags().StringVar(&recordType, "type", "", "Record type: A, AAAA, CNAME, MX, TXT, etc.") cmd.Flags().IntVar(&recordID, "record-id", 0, "Legacy: numeric record ID (most deployments do not expose IDs)") cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation prompt") + cmd.Flags().BoolVar(&showZone, "show-zone", false, "Show all records in the resulting zone") return cmd } -func runDNSRecordDeleteByName(cmd *cobra.Command, domainSlug, name, recordType string, yes bool) error { +func runDNSRecordDeleteByName(cmd *cobra.Command, domainSlug, name, recordType string, yes, showZone bool) error { if !yes && !autoApproved(cmd) { fmt.Fprintf(os.Stderr, "Delete DNS record %s %q on domain %q? [y/N]: ", recordType, name, domainSlug) scanner := bufio.NewScanner(os.Stdin) @@ -451,20 +463,27 @@ func runDNSRecordDeleteByName(cmd *cobra.Command, domainSlug, name, recordType s return fmt.Errorf("dns record-delete: resolving domain %s: %w", domainSlug, err) } fqdn := dns.CanonicalRecordFQDN(name, domain.Name) + removed := findDNSRecordsByNameAndType(domain.Records, domain.Name, fqdn, recordType) if err := svc.DeleteRecordByName(ctx, domainSlug, fqdn, recordType); err != nil { if apierrors.IsResourceNotFound(err) { fmt.Fprintf(os.Stderr, "DNS record %s %q not found (already deleted).\n", recordType, fqdn) - return nil + return printDNSRecordRows(printer, nil) } return fmt.Errorf("dns record-delete: %w", err) } - printer.Fprintf("DNS record %s %q deleted from domain %q.\n", recordType, fqdn, domainSlug) - return nil + if showZone { + domain, err = svc.Show(ctx, domainSlug) + if err != nil { + return fmt.Errorf("dns record-delete: record deleted but could not verify resulting zone: %w", err) + } + return printDNSRecordRows(printer, domain.Records) + } + return printDNSRecordRows(printer, removed) } -func runDNSRecordDelete(cmd *cobra.Command, domainSlug string, recordID int, yes bool) error { +func runDNSRecordDelete(cmd *cobra.Command, domainSlug string, recordID int, yes, showZone bool) error { if !yes && !autoApproved(cmd) { fmt.Fprintf(os.Stderr, "Delete DNS record %d on domain %q? [y/N]: ", recordID, domainSlug) scanner := bufio.NewScanner(os.Stdin) @@ -482,6 +501,19 @@ func runDNSRecordDelete(cmd *cobra.Command, domainSlug string, recordID int, yes } svc := dns.NewService(client) + + var removed *dns.Record + preReadCtx, cancelPreRead := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + domain, showErr := svc.Show(preReadCtx, domainSlug) + cancelPreRead() + if showErr == nil { + for i := range domain.Records { + if domain.Records[i].ID == strconv.Itoa(recordID) { + removed = &domain.Records[i] + break + } + } + } ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() @@ -489,11 +521,170 @@ func runDNSRecordDelete(cmd *cobra.Command, domainSlug string, recordID int, yes if err := svc.DeleteRecord(ctx, domainSlug, recordID); err != nil { if apierrors.IsResourceNotFound(err) { fmt.Fprintf(os.Stderr, "DNS record %d not found (already deleted).\n", recordID) - return nil + return printDNSRecordRows(printer, nil) } return fmt.Errorf("dns record-delete: %w", err) } - printer.Fprintf("DNS record %d deleted from domain %q.\n", recordID, domainSlug) + if showZone { + domain, err = svc.Show(ctx, domainSlug) + if err != nil { + return fmt.Errorf("dns record-delete: record deleted but could not verify resulting zone: %w", err) + } + return printDNSRecordRows(printer, domain.Records) + } + if removed == nil { + return printDNSRecordRows(printer, nil) + } + return printDNSRecordRows(printer, []dns.Record{*removed}) +} + +var dnsRecordHeaders = []string{"NAME", "TYPE", "CONTENT", "TTL", "PRIORITY"} + +func printDNSRecordRows(printer *output.Printer, records []dns.Record) error { + rows := make([][]string, 0, len(records)) + for _, record := range records { + for _, content := range recordContents(&record) { + content, priority := dnsRecordContentAndPriority(record, content) + rows = append(rows, []string{record.Name, record.Type, content, strconv.Itoa(record.TTL), priority}) + } + } + return printer.PrintTable(dnsRecordHeaders, rows) +} + +func dnsRecordContentAndPriority(record dns.Record, content string) (string, string) { + if !strings.EqualFold(record.Type, "MX") { + return content, "-" + } + fields := strings.Fields(content) + if len(fields) > 1 { + if priority, err := strconv.Atoi(fields[0]); err == nil { + return strings.Join(fields[1:], " "), strconv.Itoa(priority) + } + } + return content, strconv.Itoa(record.Priority) +} + +func findCreatedDNSRecord(domain *dns.Domain, req dns.CreateRecordRequest) *dns.Record { + fqdn := dns.CanonicalRecordFQDN(req.Name, domain.Name) + for i := range domain.Records { + record := &domain.Records[i] + if !strings.EqualFold(dns.CanonicalRecordFQDN(record.Name, domain.Name), fqdn) || !strings.EqualFold(record.Type, req.Type) { + continue + } + for _, content := range recordContents(record) { + if dnsRecordMatchesRequest(*record, content, req) { + copy := *record + copy.Contents = []string{content} + copy.Content = content + return © + } + } + } return nil } + +func recordContents(record *dns.Record) []string { + if len(record.Contents) > 0 { + return record.Contents + } + return []string{record.Content} +} + +func dnsRecordMatchesRequest(record dns.Record, content string, req dns.CreateRecordRequest) bool { + actual, actualPriority := dnsRecordContentAndPriority(record, content) + actual = comparableDNSRecordContent(record.Type, actual) + expected := comparableDNSRecordContent(record.Type, req.Content) + if actual != expected { + return false + } + if req.Priority == nil { + return true + } + return actualPriority == strconv.Itoa(*req.Priority) +} + +func normalizeTXTContent(content string) string { + decoded, ok := decodeQuotedTXTContent(content) + if ok { + return decoded + } + return content +} + +// decodeQuotedTXTContent decodes PowerDNS TXT presentation syntax only when +// the entire value is a sequence of quoted chunks. Whitespace outside chunks +// is ignored; whitespace inside chunks is part of the value. Invalid or +// unquoted values are left to callers unchanged. +func decodeQuotedTXTContent(content string) (string, bool) { + var decoded strings.Builder + sawChunk := false + for i := 0; i < len(content); { + for i < len(content) && (content[i] == ' ' || content[i] == '\t' || content[i] == '\n' || content[i] == '\r') { + i++ + } + if i == len(content) { + return decoded.String(), sawChunk + } + if content[i] != '"' { + return content, false + } + sawChunk = true + i++ + for i < len(content) && content[i] != '"' { + if content[i] != '\\' { + decoded.WriteByte(content[i]) + i++ + continue + } + i++ + if i == len(content) { + return content, false + } + if i+2 < len(content) && isASCIIDigit(content[i]) && isASCIIDigit(content[i+1]) && isASCIIDigit(content[i+2]) { + octet, err := strconv.Atoi(content[i : i+3]) + if err != nil || octet > 255 { + return content, false + } + decoded.WriteByte(byte(octet)) + i += 3 + continue + } + decoded.WriteByte(content[i]) + i++ + } + if i == len(content) { + return content, false + } + i++ + } + return decoded.String(), true +} + +func isASCIIDigit(value byte) bool { + return value >= '0' && value <= '9' +} + +func comparableDNSRecordContent(recordType, content string) string { + switch strings.ToUpper(recordType) { + case "TXT": + return normalizeTXTContent(content) + case "MX", "CNAME", "NS": + return strings.ToLower(strings.TrimSuffix(strings.TrimSpace(content), ".")) + case "A", "AAAA": + if address, err := netip.ParseAddr(strings.TrimSpace(content)); err == nil { + return address.String() + } + } + return content +} + +func findDNSRecordsByNameAndType(records []dns.Record, zoneName, fqdn, recordType string) []dns.Record { + matched := make([]dns.Record, 0) + for _, record := range records { + if strings.EqualFold(dns.CanonicalRecordFQDN(record.Name, zoneName), fqdn) && strings.EqualFold(record.Type, recordType) { + matched = append(matched, record) + } + } + return matched +} diff --git a/internal/commands/dns_record_output_test.go b/internal/commands/dns_record_output_test.go new file mode 100644 index 0000000..4c8b6c1 --- /dev/null +++ b/internal/commands/dns_record_output_test.go @@ -0,0 +1,472 @@ +package commands + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/zsoftly/zcp-cli/pkg/api/dns" + "gopkg.in/yaml.v3" +) + +func TestDNSRecordCreatePrintsOnlyCreatedValue(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/dns/domains/example-com-1/records" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"status":"Success","data":{"name":"example.com","records":[{"name":"other.example.com.","type":"A","ttl":300,"contents":["192.0.2.9"]},{"name":"www.example.com.","type":"A","ttl":3600,"contents":["192.0.2.1","192.0.2.2"]}]}}`) + })) + defer srv.Close() + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + + out, _, err := execCmd(t, NewDNSCmd(), "record-create", "--domain", "example-com-1", "--name", "www", "--type", "A", "--content", "192.0.2.2", "--api-url", srv.URL, "--output", "json") + if err != nil { + t.Fatalf("record-create error = %v", err) + } + var rows []map[string]string + if err := json.Unmarshal([]byte(out), &rows); err != nil { + t.Fatalf("JSON output = %q: %v", out, err) + } + if len(rows) != 1 || rows[0]["content"] != "192.0.2.2" || rows[0]["name"] != "www.example.com." { + t.Errorf("rows = %#v, want only created value", rows) + } + if rows[0]["priority"] != "-" { + t.Errorf("priority = %q, want '-'", rows[0]["priority"]) + } +} + +func TestDNSRecordCreateNormalizesTXTAndMXOutput(t *testing.T) { + tests := []struct { + name string + args []string + body string + want map[string]string + }{ + { + name: "TXT quoted response and request", + args: []string{"--name", "txt", "--type", "TXT", "--content", `"hello"`}, + body: `{"name":"example.com","records":[{"name":"txt.example.com.","type":"TXT","ttl":3600,"contents":["\"hello\""]}]}`, + want: map[string]string{"content": `"hello"`, "priority": "-"}, + }, + { + name: "MX zero priority", + args: []string{"--name", "@", "--type", "MX", "--content", "mail.example.com.", "--priority", "0"}, + body: `{"name":"example.com","records":[{"name":"example.com.","type":"MX","ttl":3600,"contents":["0 MAIL.EXAMPLE.COM."]}]}`, + want: map[string]string{"content": "MAIL.EXAMPLE.COM.", "priority": "0"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"status":"Success","data":%s}`, tt.body) + })) + defer srv.Close() + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + + args := append([]string{"record-create", "--domain", "example-com-1"}, tt.args...) + args = append(args, "--api-url", srv.URL, "--output", "yaml") + out, _, err := execCmd(t, NewDNSCmd(), args...) + if err != nil { + t.Fatalf("record-create error = %v", err) + } + var rows []map[string]string + if err := yaml.Unmarshal([]byte(out), &rows); err != nil { + t.Fatalf("YAML output = %q: %v", out, err) + } + if len(rows) != 1 || rows[0]["content"] != tt.want["content"] || rows[0]["priority"] != tt.want["priority"] { + t.Errorf("rows = %#v, want %#v", rows, tt.want) + } + }) + } +} + +func TestDNSRecordMatchesRequestNormalizesDomainNamesAndAddresses(t *testing.T) { + tests := []struct { + name string + record string + req string + recordT string + }{ + {"CNAME trailing dot and case", "Target.Example.COM.", "target.example.com", "CNAME"}, + {"IPv6 canonical form", "2001:0db8:0000:0000:0000:0000:0000:0001", "2001:db8::1", "AAAA"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if !dnsRecordMatchesRequest(dnsRecord(tt.recordT), tt.record, dnsRequest(tt.recordT, tt.req)) { + t.Errorf("dnsRecordMatchesRequest(%q, %q) = false, want true", tt.record, tt.req) + } + }) + } +} + +func TestDNSRecordMatchesRequestDecodesQuotedTXT(t *testing.T) { + tests := []struct { + name string + actual string + expected string + match bool + }{ + {"escaped quotes", `"This \"is\" valid"`, `This "is" valid`, true}, + {"escaped backslash", `"This is also \\ valid"`, `This is also \ valid`, true}, + {"decimal UTF-8", `"\195\167"`, "รง", true}, + {"quoted chunks", `"one" "two"`, "onetwo", true}, + {"whitespace outside quoted chunks", " \t\"one\" \n \"two\" ", "onetwo", true}, + {"comma", `"one,two"`, "one,two", true}, + {"whitespace inside quote", `" leading and trailing "`, " leading and trailing ", true}, + {"unquoted whitespace preserved", " leading and trailing ", " leading and trailing ", true}, + {"different values do not match", `"one"`, "two", false}, + {"invalid quoted value remains raw", `"one`, "one", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := dnsRecordMatchesRequest(dnsRecord("TXT"), tt.actual, dnsRequest("TXT", tt.expected)) + if got != tt.match { + t.Errorf("dnsRecordMatchesRequest(%q, %q) = %v, want %v", tt.actual, tt.expected, got, tt.match) + } + }) + } +} + +func dnsRecord(recordType string) dns.Record { + return dns.Record{Type: recordType} +} + +func dnsRequest(recordType, content string) dns.CreateRecordRequest { + return dns.CreateRecordRequest{Type: recordType, Content: content} +} + +func TestDNSRecordCreateShowZoneAndFallback(t *testing.T) { + var showCalls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.Method { + case http.MethodPost: + fmt.Fprint(w, `{"status":"Success","data":{"name":"example.com"}}`) + case http.MethodGet: + showCalls++ + fmt.Fprint(w, `{"status":"Success","data":{"name":"example.com","records":[{"name":"www.example.com.","type":"A","ttl":60,"contents":["192.0.2.1"]},{"name":"other.example.com.","type":"A","ttl":60,"contents":["192.0.2.2"]}]}}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + + out, _, err := execCmd(t, NewDNSCmd(), "record-create", "--domain", "example-com-1", "--name", "www", "--type", "A", "--content", "192.0.2.1", "--show-zone", "--api-url", srv.URL, "--output", "json") + if err != nil { + t.Fatalf("record-create --show-zone error = %v", err) + } + var rows []map[string]string + if err := json.Unmarshal([]byte(out), &rows); err != nil { + t.Fatalf("JSON output = %q: %v", out, err) + } + if showCalls != 1 || len(rows) != 2 { + t.Errorf("show calls = %d, rows = %#v; want fallback and full zone", showCalls, rows) + } +} + +func TestDNSRecordCreateFallsBackOnceAndSelectsCreatedValue(t *testing.T) { + var postCalls, showCalls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.Method { + case http.MethodPost: + postCalls++ + fmt.Fprint(w, `{"status":"Success","data":{"name":"example.com"}}`) + case http.MethodGet: + showCalls++ + fmt.Fprint(w, `{"status":"Success","data":{"name":"example.com","records":[{"name":"created.example.com.","type":"A","ttl":60,"contents":["192.0.2.1"]},{"name":"other.example.com.","type":"A","ttl":60,"contents":["192.0.2.2"]}]}}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + + out, _, err := execCmd(t, NewDNSCmd(), "record-create", "--domain", "example-com-1", "--name", "created", "--type", "A", "--content", "192.0.2.1", "--api-url", srv.URL, "--output", "json") + if err != nil { + t.Fatalf("record-create error = %v", err) + } + var rows []map[string]string + if err := json.Unmarshal([]byte(out), &rows); err != nil { + t.Fatalf("JSON output = %q: %v", out, err) + } + if postCalls != 1 || showCalls != 1 || len(rows) != 1 || rows[0]["name"] != "created.example.com." { + t.Errorf("post=%d show=%d rows=%#v, want one write and selected fallback value", postCalls, showCalls, rows) + } +} + +func TestDNSRecordCreateUnverifiedOutputWarnsAndReturnsEmptyStructuredRows(t *testing.T) { + for _, format := range []string{"json", "yaml"} { + t.Run(format, func(t *testing.T) { + var postCalls, showCalls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.Method { + case http.MethodPost: + postCalls++ + fmt.Fprint(w, `{"status":"Success","data":{"name":"example.com"}}`) + case http.MethodGet: + showCalls++ + fmt.Fprint(w, `{"status":"Success","data":{"name":"example.com","records":[{"name":"other.example.com.","type":"A","ttl":60,"contents":["192.0.2.2"]}]}}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + + out, stderr, err := execCaptureStdio(t, NewDNSCmd(), "record-create", "--domain", "example-com-1", "--name", "created", "--type", "A", "--content", "192.0.2.1", "--api-url", srv.URL, "--output", format) + if err != nil { + t.Fatalf("record-create error = %v", err) + } + if postCalls != 1 || showCalls != 1 || !strings.Contains(stderr, "could not be matched") { + t.Errorf("post=%d show=%d stderr=%q, want one write, one lookup, and warning", postCalls, showCalls, stderr) + } + if format == "json" && strings.TrimSpace(out) != "[]" { + t.Errorf("JSON output = %q, want []", out) + } + if format == "yaml" { + var rows []map[string]string + if err := yaml.Unmarshal([]byte(out), &rows); err != nil || len(rows) != 0 { + t.Errorf("YAML output = %q rows=%#v error=%v, want empty list", out, rows, err) + } + } + }) + } +} + +func TestDNSRecordDeleteReportsRemovedValuesAndShowZone(t *testing.T) { + var getCalls, deleteCalls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.Method { + case http.MethodGet: + getCalls++ + if getCalls == 1 { + fmt.Fprint(w, `{"status":"Success","data":{"name":"example.com","records":[{"name":"www.example.com.","type":"A","ttl":3600,"contents":["192.0.2.1","192.0.2.2"]},{"name":"other.example.com.","type":"A","ttl":300,"contents":["192.0.2.9"]}]}}`) + return + } + fmt.Fprint(w, `{"status":"Success","data":{"name":"example.com","records":[{"name":"other.example.com.","type":"A","ttl":300,"contents":["192.0.2.9"]}]}}`) + case http.MethodDelete: + deleteCalls++ + fmt.Fprint(w, `{"status":"Success"}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + + out, _, err := execCmd(t, NewDNSCmd(), "record-delete", "--domain", "example-com-1", "--name", "www", "--type", "A", "--yes", "--api-url", srv.URL, "--output", "json") + if err != nil { + t.Fatalf("record-delete error = %v", err) + } + var removed []map[string]string + if err := json.Unmarshal([]byte(out), &removed); err != nil { + t.Fatalf("JSON output = %q: %v", out, err) + } + if deleteCalls != 1 || getCalls != 1 || len(removed) != 2 || removed[1]["content"] != "192.0.2.2" { + t.Errorf("get=%d delete=%d removed=%#v, want both values", getCalls, deleteCalls, removed) + } + + getCalls = 0 + out, _, err = execCmd(t, NewDNSCmd(), "record-delete", "--domain", "example-com-1", "--name", "www", "--type", "A", "--yes", "--show-zone", "--api-url", srv.URL, "--output", "json") + if err != nil { + t.Fatalf("record-delete --show-zone error = %v", err) + } + var remaining []map[string]string + if err := json.Unmarshal([]byte(out), &remaining); err != nil { + t.Fatalf("JSON output = %q: %v", out, err) + } + if len(remaining) != 1 || remaining[0]["name"] != "other.example.com." { + t.Errorf("remaining = %#v, want resulting zone", remaining) + } +} + +func TestDNSRecordDeleteMissingAndFailureDoNotFabricateRows(t *testing.T) { + t.Run("stale pre-read still deletes", func(t *testing.T) { + var deleteCalls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"status":"Success","data":{"name":"example.com","records":[]}}`) + return + } + deleteCalls++ + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"status":"Success"}`) + })) + defer srv.Close() + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + + out, _, err := execCmd(t, NewDNSCmd(), "record-delete", "--domain", "example-com-1", "--name", "www", "--type", "A", "--yes", "--api-url", srv.URL, "--output", "json") + if err != nil || strings.TrimSpace(out) != "[]" || deleteCalls != 1 { + t.Errorf("error=%v output=%q deleteCalls=%d, want empty result after delete", err, out, deleteCalls) + } + }) + + t.Run("already deleted", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + fmt.Fprint(w, `{"status":"Success","data":{"name":"example.com","records":[{"name":"www.example.com.","type":"A","ttl":60,"contents":["192.0.2.1"]}]}}`) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + + out, _, err := execCmd(t, NewDNSCmd(), "record-delete", "--domain", "example-com-1", "--name", "www", "--type", "A", "--yes", "--api-url", srv.URL, "--output", "json") + if err != nil || strings.TrimSpace(out) != "[]" { + t.Errorf("error=%v output=%q, want empty result for an already deleted record", err, out) + } + }) + + t.Run("delete failure", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"status":"Success","data":{"name":"example.com","records":[{"name":"www.example.com.","type":"A","ttl":60,"contents":["192.0.2.1"]}]}}`) + return + } + http.Error(w, "unavailable", http.StatusServiceUnavailable) + })) + defer srv.Close() + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + + out, _, err := execCmd(t, NewDNSCmd(), "record-delete", "--domain", "example-com-1", "--name", "www", "--type", "A", "--yes", "--api-url", srv.URL, "--output", "json") + if err == nil || strings.Contains(out, "192.0.2.1") { + t.Errorf("error=%v output=%q, want failure with no record result", err, out) + } + }) +} + +func TestDNSRecordDeleteLegacyIDReportsRecord(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + fmt.Fprint(w, `{"status":"Success","data":{"name":"example.com","records":[{"id":"42","name":"www.example.com.","type":"A","ttl":60,"content":"192.0.2.1"}]}}`) + return + } + fmt.Fprint(w, `{"status":"Success"}`) + })) + defer srv.Close() + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + + out, _, err := execCmd(t, NewDNSCmd(), "record-delete", "--domain", "example-com-1", "--record-id", "42", "--yes", "--api-url", srv.URL, "--output", "json") + if err != nil || !strings.Contains(out, "192.0.2.1") { + t.Errorf("error=%v output=%q, want legacy record details", err, out) + } +} + +func TestDNSRecordDeleteLegacyIDPreReadIsBestEffort(t *testing.T) { + tests := []struct { + name string + firstShow int + showZone bool + deleteCode int + postShow int + }{ + {"show error", http.StatusForbidden, false, http.StatusOK, http.StatusOK}, + {"record ID missing", http.StatusOK, false, http.StatusOK, http.StatusOK}, + {"show zone after failed pre-read", http.StatusForbidden, true, http.StatusOK, http.StatusOK}, + {"show zone post-read error", http.StatusOK, true, http.StatusOK, http.StatusForbidden}, + {"delete error remains error", http.StatusForbidden, false, http.StatusServiceUnavailable, http.StatusOK}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var showCalls, deleteCalls int + deleted := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + showCalls++ + if !deleted && tt.firstShow != http.StatusOK { + http.Error(w, "pre-read unavailable", tt.firstShow) + return + } + w.Header().Set("Content-Type", "application/json") + if tt.showZone && deleted { + if tt.postShow != http.StatusOK { + http.Error(w, "post-read unavailable", tt.postShow) + return + } + fmt.Fprint(w, `{"status":"Success","data":{"name":"example.com","records":[{"id":"43","name":"remaining.example.com.","type":"A","ttl":60,"contents":["192.0.2.2"]}]}}`) + return + } + fmt.Fprint(w, `{"status":"Success","data":{"name":"example.com","records":[{"id":"43","name":"other.example.com.","type":"A","ttl":60,"contents":["192.0.2.2"]}]}}`) + case http.MethodDelete: + deleteCalls++ + if tt.deleteCode != http.StatusOK { + http.Error(w, "delete unavailable", tt.deleteCode) + return + } + w.Header().Set("Content-Type", "application/json") + deleted = true + fmt.Fprint(w, `{"status":"Success"}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + + args := []string{"record-delete", "--domain", "example-com-1", "--record-id", "42", "--yes", "--api-url", srv.URL, "--output", "json"} + if tt.showZone { + args = append(args, "--show-zone") + } + out, _, err := execCmd(t, NewDNSCmd(), args...) + if tt.deleteCode != http.StatusOK || tt.postShow != http.StatusOK { + if err == nil || deleteCalls != 1 || strings.Contains(out, "remaining.example.com") { + t.Errorf("error=%v delete=%d output=%q, want error without rows", err, deleteCalls, out) + } + return + } + if err != nil || deleteCalls != 1 { + t.Fatalf("error=%v delete=%d, want successful delete", err, deleteCalls) + } + if tt.showZone { + if showCalls < 2 || !strings.Contains(out, "remaining.example.com") { + t.Errorf("show=%d output=%q, want post-delete zone", showCalls, out) + } + } else if strings.TrimSpace(out) != "[]" { + t.Errorf("output=%q, want empty rows when pre-read could not identify record", out) + } + }) + } +} + +func TestDNSRecordDeleteLegacyIDGetsFreshTimeoutAfterPreRead(t *testing.T) { + var deleteCalls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + <-r.Context().Done() + case http.MethodDelete: + deleteCalls++ + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"status":"Success"}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + + out, _, err := execCmd(t, NewDNSCmd(), "record-delete", "--domain", "example-com-1", "--record-id", "42", "--yes", "--timeout", "1", "--api-url", srv.URL, "--output", "json") + if err != nil || deleteCalls != 1 || strings.TrimSpace(out) != "[]" { + t.Errorf("error=%v delete=%d output=%q, want successful delete with empty rows", err, deleteCalls, out) + } +} From e1e7058e4068534287d34aa131eba37446994c00 Mon Sep 17 00:00:00 2001 From: godsonten Date: Mon, 19 Oct 2026 17:45:50 +0100 Subject: [PATCH 2/5] fix: honor DNS show-zone after already-absent deletes Signed-off-by: godsonten --- internal/commands/dns.go | 14 ++- internal/commands/dns_record_output_test.go | 94 +++++++++++++++++++++ 2 files changed, 104 insertions(+), 4 deletions(-) diff --git a/internal/commands/dns.go b/internal/commands/dns.go index a3de8b0..cb56a55 100644 --- a/internal/commands/dns.go +++ b/internal/commands/dns.go @@ -468,9 +468,12 @@ func runDNSRecordDeleteByName(cmd *cobra.Command, domainSlug, name, recordType s if err := svc.DeleteRecordByName(ctx, domainSlug, fqdn, recordType); err != nil { if apierrors.IsResourceNotFound(err) { fmt.Fprintf(os.Stderr, "DNS record %s %q not found (already deleted).\n", recordType, fqdn) - return printDNSRecordRows(printer, nil) + if !showZone { + return printDNSRecordRows(printer, nil) + } + } else { + return fmt.Errorf("dns record-delete: %w", err) } - return fmt.Errorf("dns record-delete: %w", err) } if showZone { @@ -521,9 +524,12 @@ func runDNSRecordDelete(cmd *cobra.Command, domainSlug string, recordID int, yes if err := svc.DeleteRecord(ctx, domainSlug, recordID); err != nil { if apierrors.IsResourceNotFound(err) { fmt.Fprintf(os.Stderr, "DNS record %d not found (already deleted).\n", recordID) - return printDNSRecordRows(printer, nil) + if !showZone { + return printDNSRecordRows(printer, nil) + } + } else { + return fmt.Errorf("dns record-delete: %w", err) } - return fmt.Errorf("dns record-delete: %w", err) } if showZone { diff --git a/internal/commands/dns_record_output_test.go b/internal/commands/dns_record_output_test.go index 4c8b6c1..b31d52f 100644 --- a/internal/commands/dns_record_output_test.go +++ b/internal/commands/dns_record_output_test.go @@ -353,6 +353,100 @@ func TestDNSRecordDeleteMissingAndFailureDoNotFabricateRows(t *testing.T) { }) } +func TestDNSRecordDeleteAlreadyDeletedShowZone(t *testing.T) { + tests := []struct { + name string + args []string + showZone bool + postShowError bool + }{ + { + name: "name and type without show zone", + args: []string{"--name", "www", "--type", "A"}, + }, + { + name: "name and type with show zone", + args: []string{"--name", "www", "--type", "A"}, + showZone: true, + }, + { + name: "name and type with show zone post read failure", + args: []string{"--name", "www", "--type", "A"}, + showZone: true, + postShowError: true, + }, + { + name: "record ID without show zone", + args: []string{"--record-id", "42"}, + }, + { + name: "record ID with show zone", + args: []string{"--record-id", "42"}, + showZone: true, + }, + { + name: "record ID with show zone post read failure", + args: []string{"--record-id", "42"}, + showZone: true, + postShowError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var showCalls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + showCalls++ + if showCalls > 1 && tt.postShowError { + http.Error(w, "post-read unavailable", http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + if showCalls > 1 { + fmt.Fprint(w, `{"status":"Success","data":{"name":"example.com","records":[{"name":"remaining.example.com.","type":"A","ttl":60,"contents":["192.0.2.2"]}]}}`) + return + } + fmt.Fprint(w, `{"status":"Success","data":{"name":"example.com","records":[{"id":"42","name":"www.example.com.","type":"A","ttl":60,"contents":["192.0.2.1"]}]}}`) + case http.MethodDelete: + http.NotFound(w, r) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + + args := append([]string{"record-delete", "--domain", "example-com-1", "--yes"}, tt.args...) + if tt.showZone { + args = append(args, "--show-zone") + } + args = append(args, "--api-url", srv.URL, "--output", "json") + out, _, err := execCmd(t, NewDNSCmd(), args...) + + if tt.postShowError { + if err == nil || showCalls < 2 || strings.Contains(out, "remaining.example.com") { + t.Errorf("error=%v show=%d output=%q, want post-read error without rows", err, showCalls, out) + } + return + } + if err != nil { + t.Fatalf("record-delete error = %v", err) + } + if tt.showZone { + if showCalls != 2 || !strings.Contains(out, "remaining.example.com") { + t.Errorf("show=%d output=%q, want remaining zone", showCalls, out) + } + return + } + if showCalls != 1 || strings.TrimSpace(out) != "[]" { + t.Errorf("show=%d output=%q, want empty output without show zone", showCalls, out) + } + }) + } +} + func TestDNSRecordDeleteLegacyIDReportsRecord(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") From 0e0608669831ae35ff44f6c3deaae0091138eea3 Mon Sep 17 00:00:00 2001 From: godsonten Date: Mon, 19 Oct 2026 20:30:41 +0100 Subject: [PATCH 3/5] fix: normalize CAA output and warn on missing delete details Signed-off-by: godsonten --- CHANGELOG.md | 2 +- docs/commands.md | 2 + internal/commands/dns.go | 60 ++++++++++ internal/commands/dns_record_output_test.go | 115 +++++++++++++++----- 4 files changed, 152 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index edc9bdc..86e5385 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), using ### Fixed -- `dns record-create` prints one row for the value it created, and `dns record-delete` prints rows for the values removed from the selected record set in table, JSON, and YAML output. Both commands support `--show-zone` to print the resulting zone instead. MX output includes its preference, including `0`; `dns show` remains the full-zone read command. +- `dns record-create` prints one row for the value it created. `dns record-delete` prints the values removed from the selected record set in table, JSON, and YAML output. Both commands support `--show-zone` to print the resulting zone. CAA matching accepts equivalent numeric flags, tag case, and quoted values. MX output includes its preference, including `0`. `dns show` remains the full-zone read command. If a successful delete cannot identify the removed values, the CLI prints an empty result and a warning. ## [v1.0.1] - 2026-10-11 diff --git a/docs/commands.md b/docs/commands.md index 9b12979..24ac495 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -441,6 +441,8 @@ zcp dns record-create --domain --name www --type A --content 192.0 zcp dns record-create --domain --name @ --type MX --content mail.example.com. --priority 10 --ttl 3600 # Record creation and deletion show only the affected values; use --show-zone # to print every record in the resulting zone. +# CAA create matching accepts equivalent numeric flags, tag case, and quoted values. +# If a successful delete cannot identify removed values, it prints an empty result and a warning. zcp dns record-create --domain --name www --type A --content 192.0.2.1 --show-zone # Delete a record set (records are addressed by name and type) or a domain diff --git a/internal/commands/dns.go b/internal/commands/dns.go index cb56a55..cf796bb 100644 --- a/internal/commands/dns.go +++ b/internal/commands/dns.go @@ -483,6 +483,9 @@ func runDNSRecordDeleteByName(cmd *cobra.Command, domainSlug, name, recordType s } return printDNSRecordRows(printer, domain.Records) } + if len(removed) == 0 { + fmt.Fprintf(os.Stderr, "DNS record deleted, but its previous values are unavailable. Use dns show %q to inspect the remaining zone.\n", domainSlug) + } return printDNSRecordRows(printer, removed) } @@ -540,6 +543,7 @@ func runDNSRecordDelete(cmd *cobra.Command, domainSlug string, recordID int, yes return printDNSRecordRows(printer, domain.Records) } if removed == nil { + fmt.Fprintf(os.Stderr, "DNS record deleted, but its previous values are unavailable. Use dns show %q to inspect the remaining zone.\n", domainSlug) return printDNSRecordRows(printer, nil) } return printDNSRecordRows(printer, []dns.Record{*removed}) @@ -598,6 +602,10 @@ func recordContents(record *dns.Record) []string { } func dnsRecordMatchesRequest(record dns.Record, content string, req dns.CreateRecordRequest) bool { + if strings.EqualFold(record.Type, "CAA") { + return caaRecordMatches(content, req.Content) + } + actual, actualPriority := dnsRecordContentAndPriority(record, content) actual = comparableDNSRecordContent(record.Type, actual) expected := comparableDNSRecordContent(record.Type, req.Content) @@ -610,6 +618,58 @@ func dnsRecordMatchesRequest(record dns.Record, content string, req dns.CreateRe return actualPriority == strconv.Itoa(*req.Priority) } +func caaRecordMatches(actual, expected string) bool { + actualFlags, actualTag, actualValue, actualOK := parseCAAContent(actual) + expectedFlags, expectedTag, expectedValue, expectedOK := parseCAAContent(expected) + if !actualOK || !expectedOK { + return actual == expected + } + return actualFlags == expectedFlags && strings.EqualFold(actualTag, expectedTag) && actualValue == expectedValue +} + +func parseCAAContent(content string) (uint8, string, string, bool) { + content = strings.TrimSpace(content) + flagsEnd := strings.IndexAny(content, " \t\n\r") + if flagsEnd <= 0 { + return 0, "", "", false + } + flags, err := strconv.ParseUint(content[:flagsEnd], 10, 8) + if err != nil { + return 0, "", "", false + } + + content = strings.TrimLeft(content[flagsEnd:], " \t\n\r") + tagEnd := strings.IndexAny(content, " \t\n\r") + if tagEnd <= 0 { + return 0, "", "", false + } + tag := content[:tagEnd] + if !isCAATag(tag) { + return 0, "", "", false + } + + value := strings.TrimSpace(content[tagEnd:]) + if value == "" { + return 0, "", "", false + } + if decoded, ok := decodeQuotedTXTContent(value); ok { + return uint8(flags), tag, decoded, true + } + if strings.ContainsAny(value, " \t\n\r") { + return 0, "", "", false + } + return uint8(flags), tag, value, true +} + +func isCAATag(tag string) bool { + for i := range tag { + if (tag[i] < 'a' || tag[i] > 'z') && (tag[i] < 'A' || tag[i] > 'Z') && (tag[i] < '0' || tag[i] > '9') { + return false + } + } + return true +} + func normalizeTXTContent(content string) string { decoded, ok := decodeQuotedTXTContent(content) if ok { diff --git a/internal/commands/dns_record_output_test.go b/internal/commands/dns_record_output_test.go index b31d52f..3e44a2a 100644 --- a/internal/commands/dns_record_output_test.go +++ b/internal/commands/dns_record_output_test.go @@ -137,6 +137,69 @@ func TestDNSRecordMatchesRequestDecodesQuotedTXT(t *testing.T) { } } +func TestDNSRecordMatchesRequestNormalizesCAA(t *testing.T) { + tests := []struct { + name string + actual string + expected string + match bool + }{ + {"quoted API value, unquoted request", `0 ISSUE "letsencrypt.org"`, "000 issue letsencrypt.org", true}, + {"value preserves internal spaces", `128 iodef "mailto:security@example.com urgent"`, `128 IODEF "mailto:security@example.com urgent"`, true}, + {"different flag", `128 issue "letsencrypt.org"`, "0 issue letsencrypt.org", false}, + {"different tag", `0 iodef "letsencrypt.org"`, "0 issue letsencrypt.org", false}, + {"different value case", `0 issue "LetsEncrypt.org"`, "0 issue letsencrypt.org", false}, + {"different value", `0 issue "other.example"`, "0 issue letsencrypt.org", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := dnsRecordMatchesRequest(dnsRecord("CAA"), tt.actual, dnsRequest("CAA", tt.expected)) + if got != tt.match { + t.Errorf("dnsRecordMatchesRequest(%q, %q) = %v, want %v", tt.actual, tt.expected, got, tt.match) + } + }) + } +} + +func TestDNSRecordCreateNormalizesCAAOutput(t *testing.T) { + for _, format := range []string{"table", "json", "yaml"} { + t.Run(format, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"status":"Success","data":{"name":"example.com","records":[{"name":"caa.example.com.","type":"CAA","ttl":3600,"contents":["128 issue \"letsencrypt.org\"","0 iodef \"letsencrypt.org\"","0 issue \"LetsEncrypt.org\"","0 issue \"other.example\"","0 ISSUE \"letsencrypt.org\""]}]}}`) + })) + defer srv.Close() + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + + out, _, err := execCmd(t, NewDNSCmd(), "record-create", "--domain", "example-com-1", "--name", "caa", "--type", "CAA", "--content", "000 issue letsencrypt.org", "--api-url", srv.URL, "--output", format) + if err != nil { + t.Fatalf("record-create error = %v", err) + } + const want = `0 ISSUE "letsencrypt.org"` + switch format { + case "table": + if !strings.Contains(out, want) || strings.Contains(out, "other.example") || strings.Contains(out, "LetsEncrypt.org") { + t.Errorf("table output = %q, want only %q", out, want) + } + case "json": + var rows []map[string]string + if err := json.Unmarshal([]byte(out), &rows); err != nil || len(rows) != 1 || rows[0]["content"] != want { + t.Errorf("JSON output = %q rows=%#v error=%v, want only %q", out, rows, err, want) + } + case "yaml": + var rows []map[string]string + if err := yaml.Unmarshal([]byte(out), &rows); err != nil || len(rows) != 1 || rows[0]["content"] != want { + t.Errorf("YAML output = %q rows=%#v error=%v, want only %q", out, rows, err, want) + } + } + }) + } +} + func dnsRecord(recordType string) dns.Record { return dns.Record{Type: recordType} } @@ -310,9 +373,9 @@ func TestDNSRecordDeleteMissingAndFailureDoNotFabricateRows(t *testing.T) { defer srv.Close() t.Setenv("ZCP_BEARER_TOKEN", "test-token") - out, _, err := execCmd(t, NewDNSCmd(), "record-delete", "--domain", "example-com-1", "--name", "www", "--type", "A", "--yes", "--api-url", srv.URL, "--output", "json") - if err != nil || strings.TrimSpace(out) != "[]" || deleteCalls != 1 { - t.Errorf("error=%v output=%q deleteCalls=%d, want empty result after delete", err, out, deleteCalls) + out, stderr, err := execCaptureStdio(t, NewDNSCmd(), "record-delete", "--domain", "example-com-1", "--name", "www", "--type", "A", "--yes", "--api-url", srv.URL, "--output", "json") + if err != nil || strings.TrimSpace(out) != "[]" || deleteCalls != 1 || !strings.Contains(stderr, "previous values are unavailable") { + t.Errorf("error=%v output=%q stderr=%q deleteCalls=%d, want empty result and warning after delete", err, out, stderr, deleteCalls) } }) @@ -328,9 +391,9 @@ func TestDNSRecordDeleteMissingAndFailureDoNotFabricateRows(t *testing.T) { defer srv.Close() t.Setenv("ZCP_BEARER_TOKEN", "test-token") - out, _, err := execCmd(t, NewDNSCmd(), "record-delete", "--domain", "example-com-1", "--name", "www", "--type", "A", "--yes", "--api-url", srv.URL, "--output", "json") - if err != nil || strings.TrimSpace(out) != "[]" { - t.Errorf("error=%v output=%q, want empty result for an already deleted record", err, out) + out, stderr, err := execCaptureStdio(t, NewDNSCmd(), "record-delete", "--domain", "example-com-1", "--name", "www", "--type", "A", "--yes", "--api-url", srv.URL, "--output", "json") + if err != nil || strings.TrimSpace(out) != "[]" || strings.Contains(stderr, "previous values are unavailable") { + t.Errorf("error=%v output=%q stderr=%q, want empty result without missing-details warning for an already deleted record", err, out, stderr) } }) @@ -346,9 +409,9 @@ func TestDNSRecordDeleteMissingAndFailureDoNotFabricateRows(t *testing.T) { defer srv.Close() t.Setenv("ZCP_BEARER_TOKEN", "test-token") - out, _, err := execCmd(t, NewDNSCmd(), "record-delete", "--domain", "example-com-1", "--name", "www", "--type", "A", "--yes", "--api-url", srv.URL, "--output", "json") - if err == nil || strings.Contains(out, "192.0.2.1") { - t.Errorf("error=%v output=%q, want failure with no record result", err, out) + out, stderr, err := execCaptureStdio(t, NewDNSCmd(), "record-delete", "--domain", "example-com-1", "--name", "www", "--type", "A", "--yes", "--api-url", srv.URL, "--output", "json") + if err == nil || strings.Contains(out, "192.0.2.1") || strings.Contains(stderr, "previous values are unavailable") { + t.Errorf("error=%v output=%q stderr=%q, want failure with no record result or missing-details warning", err, out, stderr) } }) } @@ -423,11 +486,11 @@ func TestDNSRecordDeleteAlreadyDeletedShowZone(t *testing.T) { args = append(args, "--show-zone") } args = append(args, "--api-url", srv.URL, "--output", "json") - out, _, err := execCmd(t, NewDNSCmd(), args...) + out, stderr, err := execCaptureStdio(t, NewDNSCmd(), args...) if tt.postShowError { - if err == nil || showCalls < 2 || strings.Contains(out, "remaining.example.com") { - t.Errorf("error=%v show=%d output=%q, want post-read error without rows", err, showCalls, out) + if err == nil || showCalls < 2 || strings.Contains(out, "remaining.example.com") || strings.Contains(stderr, "previous values are unavailable") { + t.Errorf("error=%v show=%d output=%q stderr=%q, want post-read error without rows or missing-details warning", err, showCalls, out, stderr) } return } @@ -435,13 +498,13 @@ func TestDNSRecordDeleteAlreadyDeletedShowZone(t *testing.T) { t.Fatalf("record-delete error = %v", err) } if tt.showZone { - if showCalls != 2 || !strings.Contains(out, "remaining.example.com") { - t.Errorf("show=%d output=%q, want remaining zone", showCalls, out) + if showCalls != 2 || !strings.Contains(out, "remaining.example.com") || strings.Contains(stderr, "previous values are unavailable") { + t.Errorf("show=%d output=%q stderr=%q, want remaining zone without missing-details warning", showCalls, out, stderr) } return } - if showCalls != 1 || strings.TrimSpace(out) != "[]" { - t.Errorf("show=%d output=%q, want empty output without show zone", showCalls, out) + if showCalls != 1 || strings.TrimSpace(out) != "[]" || strings.Contains(stderr, "previous values are unavailable") { + t.Errorf("show=%d output=%q stderr=%q, want empty output without missing-details warning", showCalls, out, stderr) } }) } @@ -521,10 +584,10 @@ func TestDNSRecordDeleteLegacyIDPreReadIsBestEffort(t *testing.T) { if tt.showZone { args = append(args, "--show-zone") } - out, _, err := execCmd(t, NewDNSCmd(), args...) + out, stderr, err := execCaptureStdio(t, NewDNSCmd(), args...) if tt.deleteCode != http.StatusOK || tt.postShow != http.StatusOK { - if err == nil || deleteCalls != 1 || strings.Contains(out, "remaining.example.com") { - t.Errorf("error=%v delete=%d output=%q, want error without rows", err, deleteCalls, out) + if err == nil || deleteCalls != 1 || strings.Contains(out, "remaining.example.com") || strings.Contains(stderr, "previous values are unavailable") { + t.Errorf("error=%v delete=%d output=%q stderr=%q, want error without rows or missing-details warning", err, deleteCalls, out, stderr) } return } @@ -532,11 +595,11 @@ func TestDNSRecordDeleteLegacyIDPreReadIsBestEffort(t *testing.T) { t.Fatalf("error=%v delete=%d, want successful delete", err, deleteCalls) } if tt.showZone { - if showCalls < 2 || !strings.Contains(out, "remaining.example.com") { - t.Errorf("show=%d output=%q, want post-delete zone", showCalls, out) + if showCalls < 2 || !strings.Contains(out, "remaining.example.com") || strings.Contains(stderr, "previous values are unavailable") { + t.Errorf("show=%d output=%q stderr=%q, want post-delete zone without missing-details warning", showCalls, out, stderr) } - } else if strings.TrimSpace(out) != "[]" { - t.Errorf("output=%q, want empty rows when pre-read could not identify record", out) + } else if strings.TrimSpace(out) != "[]" || !strings.Contains(stderr, "previous values are unavailable") { + t.Errorf("output=%q stderr=%q, want empty rows and warning when pre-read could not identify record", out, stderr) } }) } @@ -559,8 +622,8 @@ func TestDNSRecordDeleteLegacyIDGetsFreshTimeoutAfterPreRead(t *testing.T) { defer srv.Close() t.Setenv("ZCP_BEARER_TOKEN", "test-token") - out, _, err := execCmd(t, NewDNSCmd(), "record-delete", "--domain", "example-com-1", "--record-id", "42", "--yes", "--timeout", "1", "--api-url", srv.URL, "--output", "json") - if err != nil || deleteCalls != 1 || strings.TrimSpace(out) != "[]" { - t.Errorf("error=%v delete=%d output=%q, want successful delete with empty rows", err, deleteCalls, out) + out, stderr, err := execCaptureStdio(t, NewDNSCmd(), "record-delete", "--domain", "example-com-1", "--record-id", "42", "--yes", "--timeout", "1", "--api-url", srv.URL, "--output", "json") + if err != nil || deleteCalls != 1 || strings.TrimSpace(out) != "[]" || !strings.Contains(stderr, "previous values are unavailable") { + t.Errorf("error=%v delete=%d output=%q stderr=%q, want successful delete with empty rows and warning", err, deleteCalls, out, stderr) } } From 3149512cd25467bbcdf57719f661f0f355f285f0 Mon Sep 17 00:00:00 2001 From: godsonten Date: Mon, 19 Oct 2026 21:12:16 +0100 Subject: [PATCH 4/5] fix: clarify DNS deletion snapshots and tolerate lookup failures Signed-off-by: godsonten --- CHANGELOG.md | 2 +- docs/commands.md | 9 +- internal/commands/dns.go | 64 +++++-- internal/commands/dns_record_output_test.go | 188 ++++++++++++++++++-- 4 files changed, 228 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86e5385..72b6da6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), using ### Fixed -- `dns record-create` prints one row for the value it created. `dns record-delete` prints the values removed from the selected record set in table, JSON, and YAML output. Both commands support `--show-zone` to print the resulting zone. CAA matching accepts equivalent numeric flags, tag case, and quoted values. MX output includes its preference, including `0`. `dns show` remains the full-zone read command. If a successful delete cannot identify the removed values, the CLI prints an empty result and a warning. +- `dns record-create` prints one row for the value it created. `dns record-delete` prints a pre-delete snapshot of the selected record set in table, JSON, and YAML output. The snapshot may differ from the values deleted if the record set changes concurrently, and the CLI warns when it prints one. Both commands support `--show-zone` to print the resulting zone. CAA matching accepts equivalent numeric flags, tag case, and quoted values. MX output includes its preference, including `0`. `dns show` remains the full-zone read command. If a successful delete cannot identify the previous values, the CLI prints an empty result and a warning. ## [v1.0.1] - 2026-10-11 diff --git a/docs/commands.md b/docs/commands.md index 24ac495..a4b608f 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -439,14 +439,17 @@ zcp dns create --name example.com --project default-9 # Create a record zcp dns record-create --domain --name www --type A --content 192.0.2.1 zcp dns record-create --domain --name @ --type MX --content mail.example.com. --priority 10 --ttl 3600 -# Record creation and deletion show only the affected values; use --show-zone -# to print every record in the resulting zone. +# Record creation shows the affected value. By default, deletion prints a pre-delete +# snapshot and warns on stderr that concurrent changes may make it differ from values +# actually removed. With --show-zone, deletion prints the resulting zone without the snapshot warning. # CAA create matching accepts equivalent numeric flags, tag case, and quoted values. -# If a successful delete cannot identify removed values, it prints an empty result and a warning. +# If a successful delete cannot identify previous values, it prints an empty result and a warning. zcp dns record-create --domain --name www --type A --content 192.0.2.1 --show-zone # Delete a record set (records are addressed by name and type) or a domain zcp dns record-delete --domain --name www --type A +# A fully qualified name ending in a dot can be deleted when the pre-delete read is unavailable. +zcp dns record-delete --domain --name www.example.com. --type A zcp dns record-delete --domain --name www --type A --show-zone zcp dns delete ``` diff --git a/internal/commands/dns.go b/internal/commands/dns.go index cf796bb..8903c33 100644 --- a/internal/commands/dns.go +++ b/internal/commands/dns.go @@ -454,16 +454,34 @@ func runDNSRecordDeleteByName(cmd *cobra.Command, domainSlug, name, recordType s } svc := dns.NewService(client) - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) - defer cancel() - // Resolve the stored FQDN: the backend appends the zone to relative names. - domain, err := svc.Show(ctx, domainSlug) - if err != nil { - return fmt.Errorf("dns record-delete: resolving domain %s: %w", domainSlug, err) + // Read the record set before deleting it so successful commands can report + // a pre-delete snapshot. A fully qualified name does not need the zone name + // to address the DELETE endpoint, so preserve that operation when this best- + // effort read is unavailable. Relative names and the zone apex require the + // read to determine their FQDN and must therefore fail closed. + preReadCtx, cancelPreRead := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + domain, showErr := svc.Show(preReadCtx, domainSlug) + cancelPreRead() + + fqdn, suppliedFQDN := suppliedDNSRecordFQDN(name) + var snapshot []dns.Record + if showErr == nil { + if !suppliedFQDN { + fqdn = dns.CanonicalRecordFQDN(name, domain.Name) + } + snapshot = findDNSRecordsByNameAndType(domain.Records, domain.Name, fqdn, recordType) + } else { + if apierrors.IsUnauthorized(showErr) || apierrors.IsForbidden(showErr) { + return fmt.Errorf("dns record-delete: resolving domain %s: %w", domainSlug, showErr) + } + if !suppliedFQDN { + return fmt.Errorf("dns record-delete: could not resolve relative record name %q because reading domain %q failed: %w; use a fully qualified name ending in a dot", name, domainSlug, showErr) + } } - fqdn := dns.CanonicalRecordFQDN(name, domain.Name) - removed := findDNSRecordsByNameAndType(domain.Records, domain.Name, fqdn, recordType) + + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() if err := svc.DeleteRecordByName(ctx, domainSlug, fqdn, recordType); err != nil { if apierrors.IsResourceNotFound(err) { @@ -483,10 +501,27 @@ func runDNSRecordDeleteByName(cmd *cobra.Command, domainSlug, name, recordType s } return printDNSRecordRows(printer, domain.Records) } - if len(removed) == 0 { + if len(snapshot) == 0 { fmt.Fprintf(os.Stderr, "DNS record deleted, but its previous values are unavailable. Use dns show %q to inspect the remaining zone.\n", domainSlug) + } else { + fmt.Fprintln(os.Stderr, "DNS record deleted; output is a snapshot read before deletion and may not match values removed if the record changed concurrently.") + } + return printDNSRecordRows(printer, snapshot) +} + +// suppliedDNSRecordFQDN returns a normalized FQDN only when the caller made +// its absolute form explicit with a trailing dot. It never derives a name from +// a domain slug, because slugs are not DNS zone names. +func suppliedDNSRecordFQDN(name string) (string, bool) { + name = strings.TrimSpace(name) + if !strings.HasSuffix(name, ".") { + return "", false + } + name = strings.TrimSuffix(name, ".") + if name == "" || name == "@" { + return "", false } - return printDNSRecordRows(printer, removed) + return strings.ToLower(name) + ".", true } func runDNSRecordDelete(cmd *cobra.Command, domainSlug string, recordID int, yes, showZone bool) error { @@ -508,14 +543,14 @@ func runDNSRecordDelete(cmd *cobra.Command, domainSlug string, recordID int, yes svc := dns.NewService(client) - var removed *dns.Record + var snapshot *dns.Record preReadCtx, cancelPreRead := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) domain, showErr := svc.Show(preReadCtx, domainSlug) cancelPreRead() if showErr == nil { for i := range domain.Records { if domain.Records[i].ID == strconv.Itoa(recordID) { - removed = &domain.Records[i] + snapshot = &domain.Records[i] break } } @@ -542,11 +577,12 @@ func runDNSRecordDelete(cmd *cobra.Command, domainSlug string, recordID int, yes } return printDNSRecordRows(printer, domain.Records) } - if removed == nil { + if snapshot == nil { fmt.Fprintf(os.Stderr, "DNS record deleted, but its previous values are unavailable. Use dns show %q to inspect the remaining zone.\n", domainSlug) return printDNSRecordRows(printer, nil) } - return printDNSRecordRows(printer, []dns.Record{*removed}) + fmt.Fprintln(os.Stderr, "DNS record deleted; output is a snapshot read before deletion and may not match values removed if the record changed concurrently.") + return printDNSRecordRows(printer, []dns.Record{*snapshot}) } var dnsRecordHeaders = []string{"NAME", "TYPE", "CONTENT", "TTL", "PRIORITY"} diff --git a/internal/commands/dns_record_output_test.go b/internal/commands/dns_record_output_test.go index 3e44a2a..e35b0f7 100644 --- a/internal/commands/dns_record_output_test.go +++ b/internal/commands/dns_record_output_test.go @@ -331,7 +331,7 @@ func TestDNSRecordDeleteReportsRemovedValuesAndShowZone(t *testing.T) { defer srv.Close() t.Setenv("ZCP_BEARER_TOKEN", "test-token") - out, _, err := execCmd(t, NewDNSCmd(), "record-delete", "--domain", "example-com-1", "--name", "www", "--type", "A", "--yes", "--api-url", srv.URL, "--output", "json") + out, stderr, err := execCaptureStdio(t, NewDNSCmd(), "record-delete", "--domain", "example-com-1", "--name", "www", "--type", "A", "--yes", "--api-url", srv.URL, "--output", "json") if err != nil { t.Fatalf("record-delete error = %v", err) } @@ -339,12 +339,12 @@ func TestDNSRecordDeleteReportsRemovedValuesAndShowZone(t *testing.T) { if err := json.Unmarshal([]byte(out), &removed); err != nil { t.Fatalf("JSON output = %q: %v", out, err) } - if deleteCalls != 1 || getCalls != 1 || len(removed) != 2 || removed[1]["content"] != "192.0.2.2" { + if deleteCalls != 1 || getCalls != 1 || len(removed) != 2 || removed[1]["content"] != "192.0.2.2" || !strings.Contains(stderr, "snapshot read before deletion") { t.Errorf("get=%d delete=%d removed=%#v, want both values", getCalls, deleteCalls, removed) } getCalls = 0 - out, _, err = execCmd(t, NewDNSCmd(), "record-delete", "--domain", "example-com-1", "--name", "www", "--type", "A", "--yes", "--show-zone", "--api-url", srv.URL, "--output", "json") + out, stderr, err = execCaptureStdio(t, NewDNSCmd(), "record-delete", "--domain", "example-com-1", "--name", "www", "--type", "A", "--yes", "--show-zone", "--api-url", srv.URL, "--output", "json") if err != nil { t.Fatalf("record-delete --show-zone error = %v", err) } @@ -352,7 +352,7 @@ func TestDNSRecordDeleteReportsRemovedValuesAndShowZone(t *testing.T) { if err := json.Unmarshal([]byte(out), &remaining); err != nil { t.Fatalf("JSON output = %q: %v", out, err) } - if len(remaining) != 1 || remaining[0]["name"] != "other.example.com." { + if len(remaining) != 1 || remaining[0]["name"] != "other.example.com." || strings.Contains(stderr, "snapshot read before deletion") { t.Errorf("remaining = %#v, want resulting zone", remaining) } } @@ -374,7 +374,7 @@ func TestDNSRecordDeleteMissingAndFailureDoNotFabricateRows(t *testing.T) { t.Setenv("ZCP_BEARER_TOKEN", "test-token") out, stderr, err := execCaptureStdio(t, NewDNSCmd(), "record-delete", "--domain", "example-com-1", "--name", "www", "--type", "A", "--yes", "--api-url", srv.URL, "--output", "json") - if err != nil || strings.TrimSpace(out) != "[]" || deleteCalls != 1 || !strings.Contains(stderr, "previous values are unavailable") { + if err != nil || strings.TrimSpace(out) != "[]" || deleteCalls != 1 || !strings.Contains(stderr, "previous values are unavailable") || strings.Contains(stderr, "snapshot read before deletion") { t.Errorf("error=%v output=%q stderr=%q deleteCalls=%d, want empty result and warning after delete", err, out, stderr, deleteCalls) } }) @@ -392,7 +392,7 @@ func TestDNSRecordDeleteMissingAndFailureDoNotFabricateRows(t *testing.T) { t.Setenv("ZCP_BEARER_TOKEN", "test-token") out, stderr, err := execCaptureStdio(t, NewDNSCmd(), "record-delete", "--domain", "example-com-1", "--name", "www", "--type", "A", "--yes", "--api-url", srv.URL, "--output", "json") - if err != nil || strings.TrimSpace(out) != "[]" || strings.Contains(stderr, "previous values are unavailable") { + if err != nil || strings.TrimSpace(out) != "[]" || strings.Contains(stderr, "previous values are unavailable") || strings.Contains(stderr, "snapshot read before deletion") { t.Errorf("error=%v output=%q stderr=%q, want empty result without missing-details warning for an already deleted record", err, out, stderr) } }) @@ -410,12 +410,166 @@ func TestDNSRecordDeleteMissingAndFailureDoNotFabricateRows(t *testing.T) { t.Setenv("ZCP_BEARER_TOKEN", "test-token") out, stderr, err := execCaptureStdio(t, NewDNSCmd(), "record-delete", "--domain", "example-com-1", "--name", "www", "--type", "A", "--yes", "--api-url", srv.URL, "--output", "json") - if err == nil || strings.Contains(out, "192.0.2.1") || strings.Contains(stderr, "previous values are unavailable") { + if err == nil || strings.Contains(out, "192.0.2.1") || strings.Contains(stderr, "previous values are unavailable") || strings.Contains(stderr, "snapshot read before deletion") { t.Errorf("error=%v output=%q stderr=%q, want failure with no record result or missing-details warning", err, out, stderr) } }) } +func TestDNSRecordDeletePrintsPreDeleteSnapshot(t *testing.T) { + for _, format := range []string{"table", "json", "yaml"} { + t.Run(format, func(t *testing.T) { + var deleteCalls int + currentContent := "192.0.2.1" + deletedContent := "" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"status":"Success","data":{"name":"example.com","records":[{"name":"www.example.com.","type":"A","ttl":60,"contents":[%q]}]}}`, currentContent) + case http.MethodDelete: + deleteCalls++ + // The record set changes after the pre-delete read and before DELETE. + // Delete responses do not include the value that was removed. + currentContent = "192.0.2.2" + deletedContent = currentContent + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"status":"Success"}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + + out, stderr, err := execCaptureStdio(t, NewDNSCmd(), "record-delete", "--domain", "example-com-1", "--name", "www", "--type", "A", "--yes", "--api-url", srv.URL, "--output", format) + if err != nil || deleteCalls != 1 || deletedContent != "192.0.2.2" || strings.Contains(out, "192.0.2.2") || !strings.Contains(stderr, "snapshot read before deletion") || strings.Contains(out, "snapshot read before deletion") { + t.Errorf("error=%v delete=%d deleted=%q output=%q stderr=%q, want pre-delete snapshot and warning", err, deleteCalls, deletedContent, out, stderr) + } + switch format { + case "table": + if !strings.Contains(out, "192.0.2.1") { + t.Errorf("table output = %q, want pre-delete value", out) + } + case "json": + var rows []map[string]string + if err := json.Unmarshal([]byte(out), &rows); err != nil || len(rows) != 1 || rows[0]["content"] != "192.0.2.1" { + t.Errorf("JSON output = %q rows=%#v error=%v, want pre-delete value", out, rows, err) + } + case "yaml": + var rows []map[string]string + if err := yaml.Unmarshal([]byte(out), &rows); err != nil || len(rows) != 1 || rows[0]["content"] != "192.0.2.1" { + t.Errorf("YAML output = %q rows=%#v error=%v, want pre-delete value", out, rows, err) + } + } + }) + } +} + +func TestDNSRecordDeleteByNameUsesAbsoluteFQDNWhenPreReadUnavailable(t *testing.T) { + tests := []struct { + name string + showStatus int + deleteStatus int + wantDelete bool + wantError string + }{ + {"unavailable", http.StatusServiceUnavailable, http.StatusOK, true, ""}, + {"missing", http.StatusNotFound, http.StatusOK, true, ""}, + {"unauthorized", http.StatusUnauthorized, http.StatusOK, false, "resolving domain"}, + {"forbidden", http.StatusForbidden, http.StatusOK, false, "resolving domain"}, + {"failed delete", http.StatusServiceUnavailable, http.StatusServiceUnavailable, true, "dns record-delete"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var deleteCalls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + http.Error(w, "pre-read unavailable", tt.showStatus) + case http.MethodDelete: + deleteCalls++ + if r.URL.Query().Get("name") != "www.example.com." || r.URL.Query().Get("type") != "A" { + http.Error(w, "unexpected delete target", http.StatusBadRequest) + return + } + if tt.deleteStatus != http.StatusOK { + http.Error(w, "delete unavailable", tt.deleteStatus) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"status":"Success"}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + + out, stderr, err := execCaptureStdio(t, NewDNSCmd(), "record-delete", "--domain", "example-com-1", "--name", "WWW.Example.COM.", "--type", "A", "--yes", "--api-url", srv.URL, "--output", "json") + if tt.wantError != "" { + wantDeleteCalls := 0 + if tt.wantDelete { + wantDeleteCalls = 1 + } + if err == nil || !strings.Contains(err.Error(), tt.wantError) || deleteCalls != wantDeleteCalls || strings.Contains(stderr, "previous values are unavailable") || strings.Contains(stderr, "snapshot read before deletion") { + t.Errorf("error=%v delete=%d output=%q stderr=%q, want %q without a successful-delete warning", err, deleteCalls, out, stderr, tt.wantError) + } + return + } + if err != nil || deleteCalls != 1 || strings.TrimSpace(out) != "[]" || !strings.Contains(stderr, "previous values are unavailable") || strings.Contains(stderr, "snapshot read before deletion") { + t.Errorf("error=%v delete=%d output=%q stderr=%q, want successful delete with unavailable-details warning", err, deleteCalls, out, stderr) + } + }) + } +} + +func TestDNSRecordDeleteByNameRequiresLookupForRelativeNames(t *testing.T) { + for _, name := range []string{"www", "@"} { + t.Run(name, func(t *testing.T) { + var deleteCalls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + http.Error(w, "pre-read unavailable", http.StatusServiceUnavailable) + return + } + deleteCalls++ + http.Error(w, "delete must not be called", http.StatusInternalServerError) + })) + defer srv.Close() + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + + _, _, err := execCaptureStdio(t, NewDNSCmd(), "record-delete", "--domain", "example-com-1", "--name", name, "--type", "A", "--yes", "--api-url", srv.URL) + if err == nil || !strings.Contains(err.Error(), "use a fully qualified name ending in a dot") || deleteCalls != 0 { + t.Errorf("error=%v delete=%d, want actionable lookup error without delete", err, deleteCalls) + } + }) + } +} + +func TestDNSRecordDeleteByNameGetsFreshTimeoutAfterPreRead(t *testing.T) { + var deleteCalls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + <-r.Context().Done() + case http.MethodDelete: + deleteCalls++ + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"status":"Success"}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + + out, stderr, err := execCaptureStdio(t, NewDNSCmd(), "record-delete", "--domain", "example-com-1", "--name", "www.example.com.", "--type", "A", "--yes", "--timeout", "1", "--api-url", srv.URL, "--output", "json") + if err != nil || deleteCalls != 1 || strings.TrimSpace(out) != "[]" || !strings.Contains(stderr, "previous values are unavailable") || strings.Contains(stderr, "snapshot read before deletion") { + t.Errorf("error=%v delete=%d output=%q stderr=%q, want successful delete with fresh timeout and unavailable-details warning", err, deleteCalls, out, stderr) + } +} + func TestDNSRecordDeleteAlreadyDeletedShowZone(t *testing.T) { tests := []struct { name string @@ -489,7 +643,7 @@ func TestDNSRecordDeleteAlreadyDeletedShowZone(t *testing.T) { out, stderr, err := execCaptureStdio(t, NewDNSCmd(), args...) if tt.postShowError { - if err == nil || showCalls < 2 || strings.Contains(out, "remaining.example.com") || strings.Contains(stderr, "previous values are unavailable") { + if err == nil || showCalls < 2 || strings.Contains(out, "remaining.example.com") || strings.Contains(stderr, "previous values are unavailable") || strings.Contains(stderr, "snapshot read before deletion") { t.Errorf("error=%v show=%d output=%q stderr=%q, want post-read error without rows or missing-details warning", err, showCalls, out, stderr) } return @@ -498,12 +652,12 @@ func TestDNSRecordDeleteAlreadyDeletedShowZone(t *testing.T) { t.Fatalf("record-delete error = %v", err) } if tt.showZone { - if showCalls != 2 || !strings.Contains(out, "remaining.example.com") || strings.Contains(stderr, "previous values are unavailable") { + if showCalls != 2 || !strings.Contains(out, "remaining.example.com") || strings.Contains(stderr, "previous values are unavailable") || strings.Contains(stderr, "snapshot read before deletion") { t.Errorf("show=%d output=%q stderr=%q, want remaining zone without missing-details warning", showCalls, out, stderr) } return } - if showCalls != 1 || strings.TrimSpace(out) != "[]" || strings.Contains(stderr, "previous values are unavailable") { + if showCalls != 1 || strings.TrimSpace(out) != "[]" || strings.Contains(stderr, "previous values are unavailable") || strings.Contains(stderr, "snapshot read before deletion") { t.Errorf("show=%d output=%q stderr=%q, want empty output without missing-details warning", showCalls, out, stderr) } }) @@ -522,9 +676,9 @@ func TestDNSRecordDeleteLegacyIDReportsRecord(t *testing.T) { defer srv.Close() t.Setenv("ZCP_BEARER_TOKEN", "test-token") - out, _, err := execCmd(t, NewDNSCmd(), "record-delete", "--domain", "example-com-1", "--record-id", "42", "--yes", "--api-url", srv.URL, "--output", "json") - if err != nil || !strings.Contains(out, "192.0.2.1") { - t.Errorf("error=%v output=%q, want legacy record details", err, out) + out, stderr, err := execCaptureStdio(t, NewDNSCmd(), "record-delete", "--domain", "example-com-1", "--record-id", "42", "--yes", "--api-url", srv.URL, "--output", "json") + if err != nil || !strings.Contains(out, "192.0.2.1") || !strings.Contains(stderr, "snapshot read before deletion") { + t.Errorf("error=%v output=%q stderr=%q, want legacy record snapshot and warning", err, out, stderr) } } @@ -586,7 +740,7 @@ func TestDNSRecordDeleteLegacyIDPreReadIsBestEffort(t *testing.T) { } out, stderr, err := execCaptureStdio(t, NewDNSCmd(), args...) if tt.deleteCode != http.StatusOK || tt.postShow != http.StatusOK { - if err == nil || deleteCalls != 1 || strings.Contains(out, "remaining.example.com") || strings.Contains(stderr, "previous values are unavailable") { + if err == nil || deleteCalls != 1 || strings.Contains(out, "remaining.example.com") || strings.Contains(stderr, "previous values are unavailable") || strings.Contains(stderr, "snapshot read before deletion") { t.Errorf("error=%v delete=%d output=%q stderr=%q, want error without rows or missing-details warning", err, deleteCalls, out, stderr) } return @@ -595,10 +749,10 @@ func TestDNSRecordDeleteLegacyIDPreReadIsBestEffort(t *testing.T) { t.Fatalf("error=%v delete=%d, want successful delete", err, deleteCalls) } if tt.showZone { - if showCalls < 2 || !strings.Contains(out, "remaining.example.com") || strings.Contains(stderr, "previous values are unavailable") { + if showCalls < 2 || !strings.Contains(out, "remaining.example.com") || strings.Contains(stderr, "previous values are unavailable") || strings.Contains(stderr, "snapshot read before deletion") { t.Errorf("show=%d output=%q stderr=%q, want post-delete zone without missing-details warning", showCalls, out, stderr) } - } else if strings.TrimSpace(out) != "[]" || !strings.Contains(stderr, "previous values are unavailable") { + } else if strings.TrimSpace(out) != "[]" || !strings.Contains(stderr, "previous values are unavailable") || strings.Contains(stderr, "snapshot read before deletion") { t.Errorf("output=%q stderr=%q, want empty rows and warning when pre-read could not identify record", out, stderr) } }) @@ -623,7 +777,7 @@ func TestDNSRecordDeleteLegacyIDGetsFreshTimeoutAfterPreRead(t *testing.T) { t.Setenv("ZCP_BEARER_TOKEN", "test-token") out, stderr, err := execCaptureStdio(t, NewDNSCmd(), "record-delete", "--domain", "example-com-1", "--record-id", "42", "--yes", "--timeout", "1", "--api-url", srv.URL, "--output", "json") - if err != nil || deleteCalls != 1 || strings.TrimSpace(out) != "[]" || !strings.Contains(stderr, "previous values are unavailable") { + if err != nil || deleteCalls != 1 || strings.TrimSpace(out) != "[]" || !strings.Contains(stderr, "previous values are unavailable") || strings.Contains(stderr, "snapshot read before deletion") { t.Errorf("error=%v delete=%d output=%q stderr=%q, want successful delete with empty rows and warning", err, deleteCalls, out, stderr) } } From 1818477929ab86a0e756855e26ab1bb7f0d7fc8a Mon Sep 17 00:00:00 2001 From: godsonten Date: Mon, 19 Oct 2026 22:28:04 +0100 Subject: [PATCH 5/5] fix: label DNS deletion snapshots in every output format Signed-off-by: godsonten --- CHANGELOG.md | 2 +- docs/commands.md | 6 ++- internal/commands/dns.go | 34 ++++++++++--- internal/commands/dns_record_output_test.go | 54 +++++++++++++++------ 4 files changed, 72 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72b6da6..67863e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), using ### Fixed -- `dns record-create` prints one row for the value it created. `dns record-delete` prints a pre-delete snapshot of the selected record set in table, JSON, and YAML output. The snapshot may differ from the values deleted if the record set changes concurrently, and the CLI warns when it prints one. Both commands support `--show-zone` to print the resulting zone. CAA matching accepts equivalent numeric flags, tag case, and quoted values. MX output includes its preference, including `0`. `dns show` remains the full-zone read command. If a successful delete cannot identify the previous values, the CLI prints an empty result and a warning. +- `dns record-create` prints one row for the value it created. `dns record-delete` prints a pre-delete snapshot of the selected record set in table, JSON, and YAML output, marked by a `SOURCE` column in tables and `source: pre_delete_snapshot` in structured output. The snapshot may differ from the values deleted if the record set changes concurrently, and the CLI warns when it prints one. Both commands support `--show-zone` to print the resulting zone. CAA matching accepts equivalent numeric flags, tag case, and quoted values. MX output includes its preference, including `0`. `dns show` remains the full-zone read command. If a successful delete cannot identify the previous values, the CLI prints an empty result and a warning. ## [v1.0.1] - 2026-10-11 diff --git a/docs/commands.md b/docs/commands.md index a4b608f..b21b423 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -440,8 +440,10 @@ zcp dns create --name example.com --project default-9 zcp dns record-create --domain --name www --type A --content 192.0.2.1 zcp dns record-create --domain --name @ --type MX --content mail.example.com. --priority 10 --ttl 3600 # Record creation shows the affected value. By default, deletion prints a pre-delete -# snapshot and warns on stderr that concurrent changes may make it differ from values -# actually removed. With --show-zone, deletion prints the resulting zone without the snapshot warning. +# snapshot, marked as `Pre-delete snapshot` in table output and +# `source: pre_delete_snapshot` in JSON/YAML, and warns on stderr that concurrent +# changes may make it differ from values actually removed. With --show-zone, deletion +# prints the resulting zone without the snapshot label or warning. # CAA create matching accepts equivalent numeric flags, tag case, and quoted values. # If a successful delete cannot identify previous values, it prints an empty result and a warning. zcp dns record-create --domain --name www --type A --content 192.0.2.1 --show-zone diff --git a/internal/commands/dns.go b/internal/commands/dns.go index 8903c33..6b3819a 100644 --- a/internal/commands/dns.go +++ b/internal/commands/dns.go @@ -410,6 +410,7 @@ func newDNSRecordDeleteCmd() *cobra.Command { cmd := &cobra.Command{ Use: "record-delete", Short: "Delete a DNS record set by name and type", + Long: "Delete a DNS record set by name and type. By default, successful deletion output is a pre-delete snapshot, marked with SOURCE. Use --show-zone to show the resulting zone.", Example: ` zcp dns record-delete --domain example-com-1 --name www --type A zcp dns record-delete --domain example-com-1 --name www --type A --yes`, RunE: func(cmd *cobra.Command, args []string) error { @@ -506,7 +507,7 @@ func runDNSRecordDeleteByName(cmd *cobra.Command, domainSlug, name, recordType s } else { fmt.Fprintln(os.Stderr, "DNS record deleted; output is a snapshot read before deletion and may not match values removed if the record changed concurrently.") } - return printDNSRecordRows(printer, snapshot) + return printDNSRecordSnapshotRows(printer, snapshot) } // suppliedDNSRecordFQDN returns a normalized FQDN only when the caller made @@ -582,22 +583,43 @@ func runDNSRecordDelete(cmd *cobra.Command, domainSlug string, recordID int, yes return printDNSRecordRows(printer, nil) } fmt.Fprintln(os.Stderr, "DNS record deleted; output is a snapshot read before deletion and may not match values removed if the record changed concurrently.") - return printDNSRecordRows(printer, []dns.Record{*snapshot}) + return printDNSRecordSnapshotRows(printer, []dns.Record{*snapshot}) } var dnsRecordHeaders = []string{"NAME", "TYPE", "CONTENT", "TTL", "PRIORITY"} +var dnsRecordSnapshotHeaders = append(append([]string(nil), dnsRecordHeaders...), "SOURCE") func printDNSRecordRows(printer *output.Printer, records []dns.Record) error { rows := make([][]string, 0, len(records)) for _, record := range records { - for _, content := range recordContents(&record) { - content, priority := dnsRecordContentAndPriority(record, content) - rows = append(rows, []string{record.Name, record.Type, content, strconv.Itoa(record.TTL), priority}) - } + rows = append(rows, dnsRecordRows(record)...) } return printer.PrintTable(dnsRecordHeaders, rows) } +func printDNSRecordSnapshotRows(printer *output.Printer, records []dns.Record) error { + source := "Pre-delete snapshot" + if printer.Format() == output.FormatJSON || printer.Format() == output.FormatYAML { + source = "pre_delete_snapshot" + } + rows := make([][]string, 0, len(records)) + for _, record := range records { + for _, row := range dnsRecordRows(record) { + rows = append(rows, append(row, source)) + } + } + return printer.PrintTable(dnsRecordSnapshotHeaders, rows) +} + +func dnsRecordRows(record dns.Record) [][]string { + rows := make([][]string, 0, len(recordContents(&record))) + for _, content := range recordContents(&record) { + content, priority := dnsRecordContentAndPriority(record, content) + rows = append(rows, []string{record.Name, record.Type, content, strconv.Itoa(record.TTL), priority}) + } + return rows +} + func dnsRecordContentAndPriority(record dns.Record, content string) (string, string) { if !strings.EqualFold(record.Type, "MX") { return content, "-" diff --git a/internal/commands/dns_record_output_test.go b/internal/commands/dns_record_output_test.go index e35b0f7..aca2250 100644 --- a/internal/commands/dns_record_output_test.go +++ b/internal/commands/dns_record_output_test.go @@ -38,6 +38,9 @@ func TestDNSRecordCreatePrintsOnlyCreatedValue(t *testing.T) { if rows[0]["priority"] != "-" { t.Errorf("priority = %q, want '-'", rows[0]["priority"]) } + if _, ok := rows[0]["source"]; ok { + t.Errorf("source = %q, want absent for created record", rows[0]["source"]) + } } func TestDNSRecordCreateNormalizesTXTAndMXOutput(t *testing.T) { @@ -85,6 +88,10 @@ func TestDNSRecordCreateNormalizesTXTAndMXOutput(t *testing.T) { } if len(rows) != 1 || rows[0]["content"] != tt.want["content"] || rows[0]["priority"] != tt.want["priority"] { t.Errorf("rows = %#v, want %#v", rows, tt.want) + return + } + if _, hasSource := rows[0]["source"]; hasSource { + t.Errorf("rows = %#v, want no source for created record", rows) } }) } @@ -182,18 +189,32 @@ func TestDNSRecordCreateNormalizesCAAOutput(t *testing.T) { const want = `0 ISSUE "letsencrypt.org"` switch format { case "table": - if !strings.Contains(out, want) || strings.Contains(out, "other.example") || strings.Contains(out, "LetsEncrypt.org") { + if !strings.Contains(out, want) || strings.Contains(out, "other.example") || strings.Contains(out, "LetsEncrypt.org") || strings.Contains(out, "Pre-delete snapshot") { t.Errorf("table output = %q, want only %q", out, want) } case "json": var rows []map[string]string - if err := json.Unmarshal([]byte(out), &rows); err != nil || len(rows) != 1 || rows[0]["content"] != want { + if err := json.Unmarshal([]byte(out), &rows); err != nil { + t.Fatalf("JSON output = %q: %v", out, err) + } + if len(rows) != 1 || rows[0]["content"] != want { t.Errorf("JSON output = %q rows=%#v error=%v, want only %q", out, rows, err, want) + return + } + if _, hasSource := rows[0]["source"]; hasSource { + t.Errorf("JSON output = %q rows=%#v, want no source", out, rows) } case "yaml": var rows []map[string]string - if err := yaml.Unmarshal([]byte(out), &rows); err != nil || len(rows) != 1 || rows[0]["content"] != want { + if err := yaml.Unmarshal([]byte(out), &rows); err != nil { + t.Fatalf("YAML output = %q: %v", out, err) + } + if len(rows) != 1 || rows[0]["content"] != want { t.Errorf("YAML output = %q rows=%#v error=%v, want only %q", out, rows, err, want) + return + } + if _, hasSource := rows[0]["source"]; hasSource { + t.Errorf("YAML output = %q rows=%#v, want no source", out, rows) } } }) @@ -309,7 +330,7 @@ func TestDNSRecordCreateUnverifiedOutputWarnsAndReturnsEmptyStructuredRows(t *te } } -func TestDNSRecordDeleteReportsRemovedValuesAndShowZone(t *testing.T) { +func TestDNSRecordDeleteReportsSnapshotAndShowZone(t *testing.T) { var getCalls, deleteCalls int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -335,12 +356,12 @@ func TestDNSRecordDeleteReportsRemovedValuesAndShowZone(t *testing.T) { if err != nil { t.Fatalf("record-delete error = %v", err) } - var removed []map[string]string - if err := json.Unmarshal([]byte(out), &removed); err != nil { + var snapshot []map[string]string + if err := json.Unmarshal([]byte(out), &snapshot); err != nil { t.Fatalf("JSON output = %q: %v", out, err) } - if deleteCalls != 1 || getCalls != 1 || len(removed) != 2 || removed[1]["content"] != "192.0.2.2" || !strings.Contains(stderr, "snapshot read before deletion") { - t.Errorf("get=%d delete=%d removed=%#v, want both values", getCalls, deleteCalls, removed) + if deleteCalls != 1 || getCalls != 1 || len(snapshot) != 2 || snapshot[1]["content"] != "192.0.2.2" || snapshot[0]["source"] != "pre_delete_snapshot" || snapshot[1]["source"] != "pre_delete_snapshot" || !strings.Contains(stderr, "snapshot read before deletion") { + t.Errorf("get=%d delete=%d snapshot=%#v, want marked pre-delete values", getCalls, deleteCalls, snapshot) } getCalls = 0 @@ -354,6 +375,8 @@ func TestDNSRecordDeleteReportsRemovedValuesAndShowZone(t *testing.T) { } if len(remaining) != 1 || remaining[0]["name"] != "other.example.com." || strings.Contains(stderr, "snapshot read before deletion") { t.Errorf("remaining = %#v, want resulting zone", remaining) + } else if _, hasSource := remaining[0]["source"]; hasSource { + t.Errorf("remaining = %#v, want no source for resulting zone", remaining) } } @@ -448,18 +471,18 @@ func TestDNSRecordDeletePrintsPreDeleteSnapshot(t *testing.T) { } switch format { case "table": - if !strings.Contains(out, "192.0.2.1") { - t.Errorf("table output = %q, want pre-delete value", out) + if !strings.Contains(out, "192.0.2.1") || !strings.Contains(out, "Pre-delete snapshot") { + t.Errorf("table output = %q, want marked pre-delete snapshot", out) } case "json": var rows []map[string]string - if err := json.Unmarshal([]byte(out), &rows); err != nil || len(rows) != 1 || rows[0]["content"] != "192.0.2.1" { - t.Errorf("JSON output = %q rows=%#v error=%v, want pre-delete value", out, rows, err) + if err := json.Unmarshal([]byte(out), &rows); err != nil || len(rows) != 1 || rows[0]["content"] != "192.0.2.1" || rows[0]["source"] != "pre_delete_snapshot" { + t.Errorf("JSON output = %q rows=%#v error=%v, want marked pre-delete snapshot", out, rows, err) } case "yaml": var rows []map[string]string - if err := yaml.Unmarshal([]byte(out), &rows); err != nil || len(rows) != 1 || rows[0]["content"] != "192.0.2.1" { - t.Errorf("YAML output = %q rows=%#v error=%v, want pre-delete value", out, rows, err) + if err := yaml.Unmarshal([]byte(out), &rows); err != nil || len(rows) != 1 || rows[0]["content"] != "192.0.2.1" || rows[0]["source"] != "pre_delete_snapshot" { + t.Errorf("YAML output = %q rows=%#v error=%v, want marked pre-delete snapshot", out, rows, err) } } }) @@ -677,7 +700,8 @@ func TestDNSRecordDeleteLegacyIDReportsRecord(t *testing.T) { t.Setenv("ZCP_BEARER_TOKEN", "test-token") out, stderr, err := execCaptureStdio(t, NewDNSCmd(), "record-delete", "--domain", "example-com-1", "--record-id", "42", "--yes", "--api-url", srv.URL, "--output", "json") - if err != nil || !strings.Contains(out, "192.0.2.1") || !strings.Contains(stderr, "snapshot read before deletion") { + var rows []map[string]string + if json.Unmarshal([]byte(out), &rows) != nil || len(rows) != 1 || rows[0]["content"] != "192.0.2.1" || rows[0]["source"] != "pre_delete_snapshot" || err != nil || !strings.Contains(stderr, "snapshot read before deletion") { t.Errorf("error=%v output=%q stderr=%q, want legacy record snapshot and warning", err, out, stderr) } }