diff --git a/CHANGELOG.md b/CHANGELOG.md index 9af7820..67863e2 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. `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 ### Fixed diff --git a/docs/commands.md b/docs/commands.md index 449f3f4..b21b423 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -439,9 +439,20 @@ 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 shows the affected value. By default, deletion prints a pre-delete +# 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 # 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/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..6b3819a 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,33 +376,41 @@ 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", 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 { @@ -408,12 +420,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 +433,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) @@ -442,29 +455,77 @@ 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) + + 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) { fmt.Fprintf(os.Stderr, "DNS record %s %q not found (already deleted).\n", recordType, fqdn) - return nil + if !showZone { + return printDNSRecordRows(printer, nil) + } + } else { + return fmt.Errorf("dns record-delete: %w", err) } - 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) + } + 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 printDNSRecordSnapshotRows(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 strings.ToLower(name) + ".", true } -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 +543,19 @@ func runDNSRecordDelete(cmd *cobra.Command, domainSlug string, recordID int, yes } svc := dns.NewService(client) + + 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) { + snapshot = &domain.Records[i] + break + } + } + } ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() @@ -489,11 +563,252 @@ 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 + if !showZone { + return printDNSRecordRows(printer, nil) + } + } else { + return fmt.Errorf("dns record-delete: %w", err) + } + } + + 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 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) + } + 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 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 { + 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, "-" + } + 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 fmt.Errorf("dns record-delete: %w", err) } + return content, strconv.Itoa(record.Priority) +} - printer.Fprintf("DNS record %d deleted from domain %q.\n", recordID, domainSlug) +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 { + 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) + if actual != expected { + return false + } + if req.Priority == nil { + return true + } + 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 { + 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..aca2250 --- /dev/null +++ b/internal/commands/dns_record_output_test.go @@ -0,0 +1,807 @@ +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"]) + } + if _, ok := rows[0]["source"]; ok { + t.Errorf("source = %q, want absent for created record", rows[0]["source"]) + } +} + +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) + return + } + if _, hasSource := rows[0]["source"]; hasSource { + t.Errorf("rows = %#v, want no source for created record", rows) + } + }) + } +} + +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 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") || 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 { + 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 { + 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) + } + } + }) + } +} + +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 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") + 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, 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) + } + 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(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 + 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) + } + 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." || 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) + } +} + +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, 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") || 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) + } + }) + + 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, 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") || 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) + } + }) + + 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, 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") || 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") || !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" || 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" || rows[0]["source"] != "pre_delete_snapshot" { + t.Errorf("YAML output = %q rows=%#v error=%v, want marked pre-delete snapshot", 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 + 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, 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") || 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 + } + if err != nil { + 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") || 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") || 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) + } + }) + } +} + +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, stderr, err := execCaptureStdio(t, NewDNSCmd(), "record-delete", "--domain", "example-com-1", "--record-id", "42", "--yes", "--api-url", srv.URL, "--output", "json") + 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) + } +} + +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, 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") || 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 + } + 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") || 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") || 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) + } + }) + } +} + +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, 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") || 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) + } +}