diff --git a/documentation/SUMMARY.md b/documentation/SUMMARY.md index ac0930b087..2e4943967d 100644 --- a/documentation/SUMMARY.md +++ b/documentation/SUMMARY.md @@ -222,6 +222,7 @@ * [Writing new DNS providers](advanced-features/writing-providers.md) * [Creating new DNS Resource Types (rtypes)](advanced-features/adding-new-rtypes.md) * [Integration Tests](advanced-features/integration-tests.md) +* [Provider conversion tests](developer-info/provider-conversion-tests.md) * [Test a branch](advanced-features/test-a-branch.md) * [Unit Testing DNS Data](advanced-features/unittests.md) * [Bug Triage Process](advanced-features/bug-triage.md) diff --git a/documentation/developer-info/provider-conversion-tests.md b/documentation/developer-info/provider-conversion-tests.md new file mode 100644 index 0000000000..98468fe2ee --- /dev/null +++ b/documentation/developer-info/provider-conversion-tests.md @@ -0,0 +1,192 @@ +# Provider conversion tests + +Most providers convert between their API's native record format and +`models.RecordConfig`. Those conversion functions are only exercised by the +integration tests, which need credentials and a test zone. When a provider's +author becomes unavailable, nobody can run them. + +`pkg/providergolden` replays recorded data through those functions and compares +the result with a golden file. The recorded data is checked in, so the tests run +in `go test ./...` with no credentials and no network. + +Providers are enrolled one at a time. A provider with no recorded data is +skipped, never failed. + +## Enrolling a provider + +### 1. Record the data + +The integration tests already drive every conversion a provider has, so the +easiest way to collect the data is to record an integration run. Add `-record` +to the command you normally use: + +```shell +go test -run TestDNSProviders -timeout 1h -failfast -v ./integrationTest \ + -args -verbose -profile CLOUDFLAREAPI -record +``` + +The recording goes to `providers//testdata`, the testdata directory of +the package the provider under test is implemented in, so `CLOUDFLAREAPI` writes +to `providers/cloudflare/testdata`. Use `-recorddir` to write somewhere else. A +relative `-recorddir` is relative to the top of the repository: + +```shell +go test -run TestDNSProviders -timeout 1h -failfast -v ./integrationTest \ + -args -verbose -profile CLOUDFLAREAPI -recorddir providers/cloudflare/testdata +``` + +Either way two files are written, named after the provider's package: + +- `cloudflare.records` — every record the tests asked the provider to store, + which is what a `CheckToNative` function is given. +- `cloudflare.json` — the native record each returned record came from, read + from `RecordConfig.Original`. That is what a `CheckToRC` function is given. It + is written only for providers that fill `Original` in. + +Those are the names the tests read, so there is nothing to rename. One recording +feeds every test the provider has: a provider with two `CheckToNative` functions +replays the same `.records` file through both. Read the files before committing +them, though: they contain whatever your zone contained during the run. + +{% hint style="warning" %} +The domain the test passes to `CheckToRC` and `CheckToNative` has to be the zone +the data was recorded against, and nothing enforces it. `providergolden.Domain` +takes it from the same `_DOMAIN` variable the integration tests use, so +a recording and the test that replays it agree as long as that variable holds the +zone the data came from. Where they disagree, native records carry labels as the +API returned them, so a fixture recorded against `realzone.net` replayed by a +test that says `example.com` produces a golden whose labels are still fully +qualified: + +``` +www.realzone.net 300 IN A 192.0.2.1 +realzone.net 3600 IN MX 10 mail.example.org. +``` + +`LabelFromFQDNNoDot` and its siblings print `ERROR: ... called WRONG` when this +happens, but they return the name lowercased rather than shortened and the test +passes, so `-update` writes that golden and it becomes the baseline. Set the +domain before recording the golden, not after. + +A golden recorded against a zone other than `example.com` only matches while +`_DOMAIN` still names that zone, so it does not match in a checkout +that does not set it. Record from `example.com` when the golden is to be +committed. +{% endhint %} + +{% hint style="warning" %} +`Original` is recorded as it stands once the provider has built the zone, which +is not always what the API sent. A converter that canonicalizes a value in place +before storing the record in `Original` records a native that already carries +the canonicalization, and a golden replayed from it never exercises the line +that applies it. Check the recorded natives against the API's own responses when +a provider's converter writes to the record it was given. +{% endhint %} + +Without `-record` nothing is collected, no file is written and the provider is +not wrapped, so a normal run is unchanged. Under `-record` the provider is +wrapped in a `models.DNSProvider`, which is all the integration tests ask of it +today. A test that type-asserts a provider to an optional interface such as +`ZoneCreator` would need the wrapper to forward that interface too. + +Data can also be collected by hand. It goes in `providers//testdata` +under the package's own name, `providers/netlify/testdata/netlify.json` and +`providers/netlify/testdata/netlify.records`. The `.json` file is a JSON array +of the native records your provider's API returns: + +```json +[ + { + "content": "192.0.2.1", + "id": 42, + "name": "www.example.com", + "ttl": 3600, + "type": "A" + } +] +``` + +{% hint style="warning" %} +Record from a throwaway zone. Native records carry labels, addresses, and record +and zone IDs verbatim, which for a LAN appliance means your internal DNS ends up +in a public repository. +{% endhint %} + +### 2. Add the test + +The test adapts your conversion function to a uniform signature. That adapter is +the only code you write: + +```go +var testDomain = providergolden.Domain("NETLIFY") + +func TestToRecordConfigGolden(t *testing.T) { + providergolden.CheckToRC(t, "netlify_torecordconfig", testDomain, + func(dc *models.DomainConfig, native dnsRecord) ([]*models.RecordConfig, error) { + rc, err := toRecordConfig(dc, &native) + return []*models.RecordConfig{rc}, err + }) +} +``` + +The name is the golden file's, `netlify_torecordconfig.golden`, so name it after +the function under test. The recorded input is `netlify.json`, found from the +directory the test runs in rather than from that name, so several tests can +replay one recording. + +`providergolden.Domain` returns `$NETLIFY_DOMAIN`, or `example.com` when that +is unset, so the same test replays a recording of your own zone and the +committed fixtures. + +Use `CheckToNative` for the other direction: `toNative`, `toReq`, +`recordToCreateRequest`, or whatever your provider calls it. Its input is a list +of DNS records rather than native records, so it reads a `.records` file: + +```go +func TestToReqGolden(t *testing.T) { + providergolden.CheckToNative(t, "netlify_toreq", testDomain, + func(rc *models.RecordConfig) (*dnsRecordCreate, error) { + return toReq(rc), nil + }) +} +``` + +The `.golden` file produced by `CheckToRC` is a valid `.records` file, so the +usual way to write one is to copy it and add whatever else you want covered. + +### 3. Generate the golden files + +```shell +go test ./providers/netlify/ -update +``` + +Read the generated files before committing them. `-update` records whatever the +code does today, including bugs. + +## The golden format + +One line per record: the label, TTL, class, type and RDATA, followed by the +record's metadata when it has any: + +``` +www 300 IN A 192.0.2.1 +@ 3600 IN MX 10 mail.example.com. +fwd 0 IN URL https://example.net/landing ; includePath="no" type="temporary" wildcard="no" +``` + +`CheckToNative` writes its golden as JSON, since a native record has no +zonefile representation. + +## Updating + +Run `go test ./providers// -update` after an intentional change and +review the diff. `-update` rewrites only the golden files: the recorded inputs +are never touched, so updating a golden never needs an API key. + +## What these tests do and do not catch + +They pin a provider's conversion functions against their own past behaviour, so +they catch a refactor that changes what a provider sends or understands. They do +not catch a conversion that has been wrong since the day it was written, and +they say nothing about the correction and apply path. That is still worth having +for a provider whose integration tests nobody can run. diff --git a/integrationTest/helpers_test.go b/integrationTest/helpers_test.go index 7d2b6d44cc..9d109d05a4 100644 --- a/integrationTest/helpers_test.go +++ b/integrationTest/helpers_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/DNSControl/dnscontrol/v5/pkg/credsfile" + "github.com/DNSControl/dnscontrol/v5/pkg/providergolden" "github.com/DNSControl/dnscontrol/v5/pkg/providers" "github.com/DNSControl/dnscontrol/v5/providers/cloudflare" ) @@ -17,12 +18,18 @@ import ( var ( providerFlag = flag.String("provider", "", "Provider to run (if empty, deduced from -profile)") profileFlag = flag.String("profile", "", "Entry in profiles.json to use (if empty, copied from -provider)") + recordFlag = flag.Bool("record", false, "Write the record conversion inputs seen during the run to the provider's testdata directory") + recordDirFlag = flag.String("recorddir", "", "Directory to record into, and implies -record (default: the provider's testdata directory)") enableCFWorkers = flag.Bool("cfworkers", true, "enable CF worker tests (default false)") enableCFRedirectMode = flag.Bool("cfredirect", true, "enable CF SingleRedirect tests (default false)") enableCFFlatten = flag.Bool("cfflatten", false, "enable CF CNAME flattening tests (requires paid plan, default false)") enableCFTags = flag.Bool("cftags", false, "enable CF tag tests (requires paid plan, default false)") ) +// recorder accumulates the conversion inputs of every provider call made by +// this run, and is written out when -record is given. +var recorder = providergolden.NewRecorder() + func init() { testing.Init() @@ -38,6 +45,10 @@ func panicOnErr(err error) { } func getProvider(t *testing.T) (providers.DNSServiceProvider, string, map[string]string) { + if flag.NArg() != 0 { + t.Fatalf("unexpected argument %q; the recording directory is set with -recorddir", flag.Arg(0)) + } + if *providerFlag == "" && *profileFlag == "" { t.Log("No -provider or -profile specified") return nil, "", nil @@ -124,5 +135,39 @@ func getProvider(t *testing.T) (providers.DNSServiceProvider, string, map[string } } + if *recordFlag || *recordDirFlag != "" { + dir, err := recordingDir(provider) + if err != nil { + t.Fatal(err) + } + name, err := providergolden.ProviderName(provider) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { writeRecording(t, dir, name) }) + return providergolden.Record(provider, recorder), cfg["domain"], cfg + } + return provider, cfg["domain"], cfg } + +// recordingDir is where a recording of p is written: -recorddir when it is +// given, and otherwise p's own testdata directory. +func recordingDir(p providers.DNSServiceProvider) (string, error) { + if *recordDirFlag != "" { + return providergolden.ResolveDir(*recordDirFlag) + } + return providergolden.TestdataDir(p) +} + +// writeRecording writes everything recorded so far to dir, named after the +// provider under test. +func writeRecording(t *testing.T, dir, name string) { + written, err := recorder.WriteTo(dir, name) + for _, path := range written { + t.Logf("Recorded %s", path) + } + if err != nil { + t.Error(err) + } +} diff --git a/integrationTest/recording_test.go b/integrationTest/recording_test.go new file mode 100644 index 0000000000..78e6187403 --- /dev/null +++ b/integrationTest/recording_test.go @@ -0,0 +1,49 @@ +package main + +// Test where a recording is written. + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DNSControl/dnscontrol/v5/pkg/providergolden" + "github.com/DNSControl/dnscontrol/v5/pkg/providers" +) + +func TestARecordingIsNamedAfterTheProvidersPackageNotItsType(t *testing.T) { + provider, err := providers.CreateDNSProvider("GANDI_V5", map[string]string{"token": "not used"}, nil) + if err != nil { + t.Fatal(err) + } + + name, err := providergolden.ProviderName(provider) + if err != nil { + t.Fatalf("ProviderName() error: %v", err) + } + if name != "gandiv5" { + t.Errorf("ProviderName() = %q, want %q", name, "gandiv5") + } +} + +func TestRecordingDirResolvesRecordDirFromTheModuleRoot(t *testing.T) { + rel := filepath.Join("providers", "bind", "testdata") + + old := *recordDirFlag + *recordDirFlag = rel + defer func() { *recordDirFlag = old }() + + dir, err := recordingDir(nil) + if err != nil { + t.Fatalf("recordingDir() error: %v", err) + } + if !filepath.IsAbs(dir) { + t.Fatalf("recordingDir() = %q, want an absolute path", dir) + } + + root := strings.TrimSuffix(dir, string(filepath.Separator)+rel) + if _, err := os.Stat(filepath.Join(root, "go.mod")); err != nil { + t.Errorf("recordingDir() = %q, want a path under the directory holding go.mod: %v", dir, err) + } +} diff --git a/pkg/providergolden/providergolden.go b/pkg/providergolden/providergolden.go new file mode 100644 index 0000000000..8ca7112b87 --- /dev/null +++ b/pkg/providergolden/providergolden.go @@ -0,0 +1,365 @@ +// Package providergolden replays recorded provider data through a provider's +// record conversion functions and compares the result with a golden file. +// +// A provider is enrolled by adding one small test per conversion function. The +// test names the recorded data and adapts the provider's function to a uniform +// signature: +// +// var testDomain = providergolden.Domain("NETLIFY") +// +// func TestToRecordConfig(t *testing.T) { +// providergolden.CheckToRC(t, "netlify_torecordconfig", testDomain, +// func(dc *models.DomainConfig, n dnsRecord) ([]*models.RecordConfig, error) { +// rc, err := toRecordConfig(dc, &n) +// return []*models.RecordConfig{rc}, err +// }) +// } +// +// The data lives in the provider's testdata directory. A recording covers the +// whole provider, so the input files are named after it, and the golden file is +// named after the test that produced it: +// +// testdata/.json native records, as the provider's API returns them +// testdata/.records DNS records, in the golden line format below +// testdata/.golden the expected output +// +// is the directory the test is running in, which is the same name +// the integration tests record under, so a recording needs no renaming and one +// recording feeds every test the provider has. +// +// CheckToRC reads the .json file, CheckToNative reads the .records file, and +// both write the .golden file. A provider with no recorded data is skipped, so +// providers can be enrolled one at a time. +// +// "go test -update" rewrites the golden files from the recorded +// inputs. It never contacts a provider's API and never rewrites an input file, +// so gathering data from a live account stays a separate, explicit step. +// +// That step is Recorder, which collects both kinds of input from a provider as +// it is used. The integration tests wrap their provider in one when they are +// given "-record", and write what it collected to TestdataDir. +// +// A golden line is the record's label, TTL, class, type and RDATA, followed by +// the metadata when the record has any: +// +// www 300 IN A 192.0.2.1 +// @ 3600 IN MX 10 mail.example.com. +// fwd 0 IN URL https://example.net/landing ; includePath="no" type="temporary" wildcard="no" +package providergolden + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "io/fs" + "maps" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "testing" + + "github.com/DNSControl/dnscontrol/v5/models" + "github.com/google/go-cmp/cmp" +) + +var update = flag.Bool("update", false, "rewrite the provider conversion golden files") + +const ( + testdataDir = "testdata" + + // Extensions of the two kinds of recorded input, written by Recorder and + // read by CheckToRC and CheckToNative. + nativesExt = ".json" + recordsExt = ".records" +) + +// Domain returns the zone the data for provider was recorded against: the value +// of the provider's _DOMAIN environment variable, or "example.com" +// when that is unset. It is the variable the integration tests take their test +// zone from, so a recording and the test that replays it agree by default. +func Domain(provider string) string { + if domain := os.Getenv(provider + "_DOMAIN"); domain != "" { + return domain + } + return "example.com" +} + +// CheckToRC replays the native records recorded in testdata/.json +// through convert and compares the records it returns with +// testdata/.golden. +func CheckToRC[N any](t *testing.T, name, domain string, convert func(dc *models.DomainConfig, native N) ([]*models.RecordConfig, error)) { + t.Helper() + + input, err := inputFile(nativesExt) + if err != nil { + t.Fatal(err) + } + + data, ok, err := loadInput(testdataDir, input) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Skipf("%s has no recorded data yet", filepath.Join(testdataDir, input)) + } + + var natives []N + if err := json.Unmarshal(data, &natives); err != nil { + t.Fatalf("%s: %v", input, err) + } + + dc := models.MustNewDomainConfig(domain) + var b strings.Builder + for i, native := range natives { + recs, err := convert(dc, native) + if err != nil { + t.Fatalf("%s: record %d: %v", input, i, err) + } + for _, rc := range recs { + if rc == nil { + continue + } + b.WriteString(formatRecord(rc)) + b.WriteByte('\n') + } + } + + report(t, testdataDir, name, []byte(b.String())) +} + +// CheckToNative replays the records recorded in testdata/.records +// through convert and compares the native records it returns with +// testdata/.golden. +func CheckToNative[N any](t *testing.T, name, domain string, convert func(rc *models.RecordConfig) (N, error)) { + t.Helper() + + input, err := inputFile(recordsExt) + if err != nil { + t.Fatal(err) + } + + data, ok, err := loadInput(testdataDir, input) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Skipf("%s has no recorded data yet", filepath.Join(testdataDir, input)) + } + + recs, err := parseRecords(models.MustNewDomainConfig(domain), string(data)) + if err != nil { + t.Fatalf("%s: %v", input, err) + } + + natives := make([]N, 0, len(recs)) + for i, rc := range recs { + native, err := convert(rc) + if err != nil { + t.Fatalf("%s: record %d: %v", input, i, err) + } + natives = append(natives, native) + } + + got, err := json.MarshalIndent(natives, "", " ") + if err != nil { + t.Fatal(err) + } + + report(t, testdataDir, name, append(got, '\n')) +} + +// inputFile returns the recorded input file with extension ext that belongs to +// the provider under test: the directory the test is running in is the +// provider's package directory, and a recording is named after it. +func inputFile(ext string) (string, error) { + wd, err := os.Getwd() + if err != nil { + return "", err + } + return filepath.Base(wd) + ext, nil +} + +// loadInput reads a recorded input file. ok is false when the file does not +// exist, which means the provider has not been enrolled yet. +func loadInput(dir, filename string) (data []byte, ok bool, err error) { + data, err = os.ReadFile(filepath.Join(dir, filename)) + if errors.Is(err, fs.ErrNotExist) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + return data, true, nil +} + +// report compares got with the golden file, or rewrites the golden file when +// -update was given. +func report(t *testing.T, dir, name string, got []byte) { + t.Helper() + + skip, diff, err := compareGolden(dir, name, got, *update) + switch { + case err != nil: + t.Fatal(err) + case skip != "": + t.Skip(skip) + case diff != "": + t.Errorf("%s.golden does not match the conversion (-want +got):\n%s", name, diff) + } +} + +// compareGolden compares got with /.golden, or writes got to it when +// update is true. It returns a reason to skip when the golden file does not +// exist, and a diff when the contents differ. +func compareGolden(dir, name string, got []byte, update bool) (skip, diff string, err error) { + path := filepath.Join(dir, name+".golden") + + if update { + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", "", err + } + return "", "", os.WriteFile(path, got, 0o644) + } + + want, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + return fmt.Sprintf("%s does not exist: run \"go test . -update\" to record it", path), "", nil + } + if err != nil { + return "", "", err + } + + return "", cmp.Diff(strings.Split(string(want), "\n"), strings.Split(string(got), "\n")), nil +} + +// formatRecord renders rc as one line of a golden file. +func formatRecord(rc *models.RecordConfig) string { + var b strings.Builder + + b.WriteString(rc.Name) + b.WriteByte(' ') + b.WriteString(strconv.FormatUint(uint64(rc.TTL), 10)) + b.WriteString(" IN ") + b.WriteString(rc.Type) + b.WriteByte(' ') + b.WriteString(rc.GetRDATA().String()) + + if len(rc.Metadata) != 0 { + b.WriteString(" ;") + for _, k := range slices.Sorted(maps.Keys(rc.Metadata)) { + fmt.Fprintf(&b, " %s=%s", k, strconv.Quote(rc.Metadata[k])) + } + } + + return b.String() +} + +// parseRecords parses the golden line format. Blank lines are ignored. +func parseRecords(dc *models.DomainConfig, text string) ([]*models.RecordConfig, error) { + var recs []*models.RecordConfig + for i, line := range strings.Split(text, "\n") { + if strings.TrimSpace(line) == "" { + continue + } + rc, err := parseRecord(dc, line) + if err != nil { + return nil, fmt.Errorf("line %d: %w", i+1, err) + } + recs = append(recs, rc) + } + return recs, nil +} + +func parseRecord(dc *models.DomainConfig, line string) (*models.RecordConfig, error) { + record, metatext := cutMetadata(line) + + fields := strings.SplitN(record, " ", 5) + if len(fields) != 5 { + return nil, fmt.Errorf("%q: expected \"label ttl IN type rdata\"", line) + } + name, ttltext, class, rtype, rdata := fields[0], fields[1], fields[2], fields[3], fields[4] + + ttl, err := strconv.ParseUint(ttltext, 10, 32) + if err != nil { + return nil, fmt.Errorf("%q: %w", line, err) + } + if class != "IN" { + return nil, fmt.Errorf("%q: expected class \"IN\"", line) + } + + rc, err := dc.NewRecordConfigParse(name, uint32(ttl), rtype, rdata) + if err != nil { + return nil, err + } + + metadata, err := parseMetadata(metatext) + if err != nil { + return nil, fmt.Errorf("%q: %w", line, err) + } + if len(metadata) != 0 { + rc.Metadata = metadata + } + + return rc, nil +} + +// cutMetadata splits a line at the first semicolon that is not inside a quoted +// string. +func cutMetadata(line string) (record, metadata string) { + quoted := false + for i := 0; i < len(line); i++ { + switch line[i] { + case '\\': + i++ + case '"': + quoted = !quoted + case ';': + if !quoted { + return strings.TrimRight(line[:i], " "), line[i+1:] + } + } + } + return line, "" +} + +// parseMetadata parses a sequence of key="value" pairs. +func parseMetadata(s string) (map[string]string, error) { + metadata := map[string]string{} + s = strings.TrimLeft(s, " ") + for s != "" { + key, rest, ok := strings.Cut(s, "=") + if !ok { + return nil, fmt.Errorf("metadata %q: expected key=\"value\"", s) + } + value, rest, err := cutQuoted(rest) + if err != nil { + return nil, err + } + metadata[key] = value + s = strings.TrimLeft(rest, " ") + } + return metadata, nil +} + +// cutQuoted removes a Go-quoted string from the front of s. +func cutQuoted(s string) (value, rest string, err error) { + if !strings.HasPrefix(s, `"`) { + return "", "", fmt.Errorf("metadata %q: expected a quoted value", s) + } + for i := 1; i < len(s); i++ { + switch s[i] { + case '\\': + i++ + case '"': + value, err := strconv.Unquote(s[:i+1]) + if err != nil { + return "", "", fmt.Errorf("metadata %q: %w", s, err) + } + return value, s[i+1:], nil + } + } + return "", "", fmt.Errorf("metadata %q: unterminated quoted value", s) +} diff --git a/pkg/providergolden/providergolden_test.go b/pkg/providergolden/providergolden_test.go new file mode 100644 index 0000000000..798c1d4ac1 --- /dev/null +++ b/pkg/providergolden/providergolden_test.go @@ -0,0 +1,258 @@ +package providergolden + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DNSControl/dnscontrol/v5/models" +) + +func TestDomainReadsTheProvidersDomainVariable(t *testing.T) { + t.Setenv("VERCEL_DOMAIN", "recorded.example.net") + if got := Domain("VERCEL"); got != "recorded.example.net" { + t.Errorf("Domain() = %q, want %q", got, "recorded.example.net") + } +} + +func TestDomainFallsBackToExampleCom(t *testing.T) { + t.Setenv("VERCEL_DOMAIN", "") + if got := Domain("VERCEL"); got != "example.com" { + t.Errorf("Domain() = %q, want %q", got, "example.com") + } +} + +func TestFormatRecord(t *testing.T) { + dc := models.MustNewDomainConfig("example.com") + + tests := []struct { + name string + rc *models.RecordConfig + metadata map[string]string + want string + }{ + { + name: "A", + rc: dc.MustNewRecordConfig("www", 300, "A", "192.0.2.1"), + want: "www 300 IN A 192.0.2.1", + }, + { + name: "zero TTL is included", + rc: dc.MustNewRecordConfig("www", 0, "A", "192.0.2.1"), + want: "www 0 IN A 192.0.2.1", + }, + { + name: "MX at the apex", + rc: dc.MustNewRecordConfig("@", 3600, "MX", 10, "mail.example.com."), + want: "@ 3600 IN MX 10 mail.example.com.", + }, + { + name: "metadata is sorted and quoted", + rc: dc.MustNewRecordConfig("fwd", 300, "A", "192.0.2.1"), + metadata: map[string]string{"wildcard": "no", "includePath": "yes"}, + want: `fwd 300 IN A 192.0.2.1 ; includePath="yes" wildcard="no"`, + }, + { + name: "metadata value with a space and a quote", + rc: dc.MustNewRecordConfig("fwd", 300, "A", "192.0.2.1"), + metadata: map[string]string{"note": `a "b" c`}, + want: `fwd 300 IN A 192.0.2.1 ; note="a \"b\" c"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.metadata != nil { + tt.rc.Metadata = tt.metadata + } + if got := formatRecord(tt.rc); got != tt.want { + t.Errorf("formatRecord() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestParseRecordsRoundTrip(t *testing.T) { + input := strings.Join([]string{ + "@ 3600 IN A 192.0.2.1", + "www 0 IN A 192.0.2.2", + "@ 3600 IN MX 10 mail.example.com.", + `@ 600 IN TXT "v=spf1 include:_spf.example.net -all"`, + `@ 600 IN CAA 0 issue "letsencrypt.org"`, + "_sip._tcp 300 IN SRV 10 20 5060 sip.example.com.", + `fwd 300 IN A 192.0.2.3 ; includePath="yes" wildcard="no"`, + }, "\n") + "\n" + + recs, err := parseRecords(models.MustNewDomainConfig("example.com"), input) + if err != nil { + t.Fatalf("parseRecords() error: %v", err) + } + + var got strings.Builder + for _, rc := range recs { + got.WriteString(formatRecord(rc)) + got.WriteByte('\n') + } + if got.String() != input { + t.Errorf("round trip produced:\n%s\nwant:\n%s", got.String(), input) + } +} + +func TestParseRecordsMetadata(t *testing.T) { + recs, err := parseRecords(models.MustNewDomainConfig("example.com"), + `fwd 300 IN A 192.0.2.1 ; includePath="yes" note="a; b" wildcard="no"`) + if err != nil { + t.Fatalf("parseRecords() error: %v", err) + } + if len(recs) != 1 { + t.Fatalf("parseRecords() returned %d records, want 1", len(recs)) + } + + want := map[string]string{"includePath": "yes", "note": "a; b", "wildcard": "no"} + for k, v := range want { + if got := recs[0].Metadata[k]; got != v { + t.Errorf("Metadata[%q] = %q, want %q", k, got, v) + } + } +} + +func TestParseRecordsRejectsMalformedInput(t *testing.T) { + tests := []struct { + name string + input string + }{ + {name: "no ttl", input: "www IN A 192.0.2.1"}, + {name: "non-numeric ttl", input: "www abc IN A 192.0.2.1"}, + {name: "wrong class", input: "www 300 CH A 192.0.2.1"}, + {name: "too few fields", input: "www 300 A 192.0.2.1"}, + {name: "no rdata", input: "www 300 IN A"}, + {name: "unknown type", input: "www 300 IN NOSUCHTYPE 192.0.2.1"}, + {name: "unterminated metadata", input: `www 300 IN A 192.0.2.1 ; wildcard="no`}, + {name: "unquoted metadata", input: "www 300 IN A 192.0.2.1 ; wildcard=no"}, + } + + dc := models.MustNewDomainConfig("example.com") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := parseRecords(dc, tt.input); err == nil { + t.Errorf("parseRecords(%q) succeeded, want an error", tt.input) + } + }) + } +} + +func TestInputFileIsNamedAfterTheProvidersDirectory(t *testing.T) { + dir := filepath.Join(t.TempDir(), "cloudns") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + t.Chdir(dir) + + for ext, want := range map[string]string{nativesExt: "cloudns.json", recordsExt: "cloudns.records"} { + got, err := inputFile(ext) + if err != nil { + t.Fatalf("inputFile(%q) error: %v", ext, err) + } + if got != want { + t.Errorf("inputFile(%q) = %q, want %q", ext, got, want) + } + } +} + +func TestRecordedInputsAreNamedAfterTheirProvider(t *testing.T) { + root, err := moduleRoot() + if err != nil { + t.Fatal(err) + } + + dirs, err := filepath.Glob(filepath.Join(root, "providers", "*", testdataDir)) + if err != nil { + t.Fatal(err) + } + if len(dirs) == 0 { + t.Fatal("no provider has a testdata directory") + } + + for _, dir := range dirs { + provider := filepath.Base(filepath.Dir(dir)) + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + ext := filepath.Ext(entry.Name()) + if ext != nativesExt && ext != recordsExt { + continue + } + if want := provider + ext; entry.Name() != want { + t.Errorf("providers/%s/%s holds %s, want %s: a recorded input is named after its provider, not after a test", + provider, testdataDir, entry.Name(), want) + } + } + } +} + +func TestLoadInputReportsMissingFileAsNotEnrolled(t *testing.T) { + _, ok, err := loadInput(t.TempDir(), "absent.json") + if err != nil { + t.Fatalf("loadInput() error: %v", err) + } + if ok { + t.Error("loadInput() ok = true for a file that does not exist") + } +} + +func TestCompareGoldenSkipsWhenTheGoldenFileIsMissing(t *testing.T) { + skip, diff, err := compareGolden(t.TempDir(), "absent", []byte("anything\n"), false) + if err != nil { + t.Fatalf("compareGolden() error: %v", err) + } + if skip == "" { + t.Error("compareGolden() returned no skip reason for a missing golden file") + } + if diff != "" { + t.Errorf("compareGolden() diff = %q, want empty", diff) + } +} + +func TestCompareGoldenDetectsADifference(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "x.golden"), []byte("www 300 IN A 192.0.2.1\n"), 0o644); err != nil { + t.Fatal(err) + } + + skip, diff, err := compareGolden(dir, "x", []byte("www 300 IN A 192.0.2.99\n"), false) + if err != nil { + t.Fatalf("compareGolden() error: %v", err) + } + if skip != "" { + t.Errorf("compareGolden() skip = %q, want empty", skip) + } + if diff == "" { + t.Error("compareGolden() reported no difference between 192.0.2.1 and 192.0.2.99") + } + + if _, diff, err = compareGolden(dir, "x", []byte("www 300 IN A 192.0.2.1\n"), false); err != nil { + t.Fatalf("compareGolden() error: %v", err) + } else if diff != "" { + t.Errorf("compareGolden() diff = %q for identical content, want empty", diff) + } +} + +func TestCompareGoldenUpdateWritesTheFile(t *testing.T) { + dir := t.TempDir() + want := "www 300 IN A 192.0.2.1\n" + + if _, _, err := compareGolden(dir, "x", []byte(want), true); err != nil { + t.Fatalf("compareGolden() error: %v", err) + } + + got, err := os.ReadFile(filepath.Join(dir, "x.golden")) + if err != nil { + t.Fatal(err) + } + if string(got) != want { + t.Errorf("golden file = %q, want %q", got, want) + } +} diff --git a/pkg/providergolden/record.go b/pkg/providergolden/record.go new file mode 100644 index 0000000000..cacf277745 --- /dev/null +++ b/pkg/providergolden/record.go @@ -0,0 +1,195 @@ +package providergolden + +import ( + "encoding/json" + "errors" + "fmt" + "maps" + "os" + "path" + "path/filepath" + "reflect" + "slices" + "strings" + "sync" + + "github.com/DNSControl/dnscontrol/v5/models" +) + +// Recorder collects the inputs of a provider's record conversion functions as +// the provider is used, and writes them in the formats CheckToRC and +// CheckToNative read. Duplicates are discarded, so a long run that revisits the +// same records produces a small file. +type Recorder struct { + mu sync.Mutex + records map[string]bool + natives map[string]bool + errs []error +} + +// NewRecorder returns a Recorder that has seen nothing. +func NewRecorder() *Recorder { + return &Recorder{ + records: map[string]bool{}, + natives: map[string]bool{}, + } +} + +// Observe adds one round of conversion inputs. desired holds the records the +// provider is about to convert into its own format; existing holds the records +// it has just converted out of its own format, each carrying the native record +// it came from in RecordConfig.Original. +func (r *Recorder) Observe(desired, existing models.Records) { + r.mu.Lock() + defer r.mu.Unlock() + + for _, rc := range desired { + r.records[formatRecord(rc)] = true + } + + for _, rc := range existing { + if rc.Original == nil { + continue + } + native, err := json.Marshal(rc.Original) + if err != nil { + r.errs = append(r.errs, fmt.Errorf("%s %s: %w", rc.NameFQDN, rc.Type, err)) + continue + } + r.natives[string(native)] = true + } +} + +// WriteTo writes what has been observed to dir as .records and +// .json, sorted so that two runs of the same tests produce the same file. +// name is the provider the recording is of, as ProviderName returns it, which +// is the name CheckToRC and CheckToNative look for. +// +// A file is not written when nothing of that kind was observed: a provider that +// does not fill in RecordConfig.Original produces no .json. WriteTo +// returns the paths it wrote. +func (r *Recorder) WriteTo(dir, name string) ([]string, error) { + r.mu.Lock() + defer r.mu.Unlock() + + var written []string + + if len(r.records) != 0 { + text := strings.Join(slices.Sorted(maps.Keys(r.records)), "\n") + "\n" + path, err := writeFile(dir, name+recordsExt, []byte(text)) + if err != nil { + return written, err + } + written = append(written, path) + } + + if len(r.natives) != 0 { + natives := make([]json.RawMessage, 0, len(r.natives)) + for _, native := range slices.Sorted(maps.Keys(r.natives)) { + natives = append(natives, json.RawMessage(native)) + } + text, err := json.MarshalIndent(natives, "", " ") + if err != nil { + return written, err + } + path, err := writeFile(dir, name+nativesExt, append(text, '\n')) + if err != nil { + return written, err + } + written = append(written, path) + } + + return written, errors.Join(r.errs...) +} + +// ProviderName returns the name a recording of p is written under: the package +// p is implemented in. CheckToRC and CheckToNative take the same name from the +// directory they run in, so a recording lands where the provider's own tests +// look for it. +func ProviderName(p models.DNSProvider) (string, error) { + t := reflect.TypeOf(p) + for t != nil && t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t == nil || t.PkgPath() == "" { + return "", fmt.Errorf("cannot derive a provider name from provider type %T", p) + } + return path.Base(t.PkgPath()), nil +} + +// TestdataDir returns the testdata directory that belongs to p: +// providers//testdata under the module root, where is the +// package p is implemented in. +func TestdataDir(p models.DNSProvider) (string, error) { + name, err := ProviderName(p) + if err != nil { + return "", err + } + + root, err := moduleRoot() + if err != nil { + return "", err + } + return filepath.Join(root, "providers", name, testdataDir), nil +} + +// ResolveDir returns dir as an absolute path, resolving a relative dir against +// the module root. +func ResolveDir(dir string) (string, error) { + if filepath.IsAbs(dir) { + return dir, nil + } + root, err := moduleRoot() + if err != nil { + return "", err + } + return filepath.Join(root, dir), nil +} + +// moduleRoot returns the directory of the nearest go.mod at or above the +// working directory. +func moduleRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return "", errors.New("no go.mod above the working directory") + } + dir = parent + } +} + +// writeFile writes data to / and returns the path, creating dir +// when it does not exist. +func writeFile(dir, filename string, data []byte) (string, error) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + path := filepath.Join(dir, filename) + return path, os.WriteFile(path, data, 0o644) +} + +// Record wraps p so that rec observes every record conversion p is asked to +// perform. The wrapper is how the integration tests gather data: they drive a +// real provider in the same process, and every zone they build passes through +// GetZoneRecordsCorrections on its way to the provider's own conversion +// functions. +func Record(p models.DNSProvider, rec *Recorder) models.DNSProvider { + return &recordingProvider{DNSProvider: p, rec: rec} +} + +type recordingProvider struct { + models.DNSProvider + rec *Recorder +} + +func (p *recordingProvider) GetZoneRecordsCorrections(dc *models.DomainConfig, existing models.Records) ([]*models.Correction, int, error) { + p.rec.Observe(dc.Records, existing) + return p.DNSProvider.GetZoneRecordsCorrections(dc, existing) +} diff --git a/pkg/providergolden/record_test.go b/pkg/providergolden/record_test.go new file mode 100644 index 0000000000..e8705eec8d --- /dev/null +++ b/pkg/providergolden/record_test.go @@ -0,0 +1,323 @@ +package providergolden + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DNSControl/dnscontrol/v5/models" +) + +type fakeNative struct { + Name string `json:"name"` + Type string `json:"type"` +} + +type fakeProvider struct { + corrections []*models.Correction + count int + err error + calls int +} + +func (p *fakeProvider) GetNameservers(string) ([]*models.Nameserver, error) { return nil, nil } + +func (p *fakeProvider) GetZoneRecords(*models.DomainConfig) (models.Records, error) { + return nil, nil +} + +func (p *fakeProvider) GetZoneRecordsCorrections(*models.DomainConfig, models.Records) ([]*models.Correction, int, error) { + p.calls++ + return p.corrections, p.count, p.err +} + +func TestRecorderWritesTheRecordsItObserved(t *testing.T) { + dc := models.MustNewDomainConfig("example.com") + desired := models.Records{ + dc.MustNewRecordConfig("www", 300, "A", "192.0.2.1"), + dc.MustNewRecordConfig("@", 3600, "MX", 10, "mail.example.com."), + dc.MustNewRecordConfig("mail", 300, "A", "192.0.2.2"), + dc.MustNewRecordConfig("@", 600, "TXT", "hello world"), + } + + rec := NewRecorder() + rec.Observe(desired, nil) + + dir := t.TempDir() + written, err := rec.WriteTo(dir, "example") + if err != nil { + t.Fatalf("WriteTo() error: %v", err) + } + if want := []string{filepath.Join(dir, "example.records")}; len(written) != 1 || written[0] != want[0] { + t.Fatalf("WriteTo() wrote %v, want %v", written, want) + } + + got, err := os.ReadFile(written[0]) + if err != nil { + t.Fatal(err) + } + want := "@ 3600 IN MX 10 mail.example.com.\n" + + "@ 600 IN TXT \"hello world\"\n" + + "mail 300 IN A 192.0.2.2\n" + + "www 300 IN A 192.0.2.1\n" + if string(got) != want { + t.Errorf("example.records =\n%s\nwant:\n%s", got, want) + } + + recs, err := parseRecords(dc, string(got)) + if err != nil { + t.Fatalf("parseRecords() rejected the recorded file: %v", err) + } + if len(recs) != len(desired) { + t.Errorf("parseRecords() returned %d records, want %d", len(recs), len(desired)) + } +} + +func TestRecorderWritesTheNativeRecordsBehindTheConvertedOnes(t *testing.T) { + dc := models.MustNewDomainConfig("example.com") + + withOriginal := dc.MustNewRecordConfig("www", 300, "A", "192.0.2.1") + withOriginal.Original = fakeNative{Name: "www", Type: "A"} + withoutOriginal := dc.MustNewRecordConfig("mail", 300, "A", "192.0.2.2") + + rec := NewRecorder() + rec.Observe(nil, models.Records{withOriginal, withoutOriginal}) + + dir := t.TempDir() + written, err := rec.WriteTo(dir, "example") + if err != nil { + t.Fatalf("WriteTo() error: %v", err) + } + if want := []string{filepath.Join(dir, "example.json")}; len(written) != 1 || written[0] != want[0] { + t.Fatalf("WriteTo() wrote %v, want %v", written, want) + } + + data, err := os.ReadFile(written[0]) + if err != nil { + t.Fatal(err) + } + var natives []fakeNative + if err := json.Unmarshal(data, &natives); err != nil { + t.Fatalf("example.json: %v", err) + } + if len(natives) != 1 { + t.Fatalf("example.json holds %d natives, want 1", len(natives)) + } + if want := (fakeNative{Name: "www", Type: "A"}); natives[0] != want { + t.Errorf("example.json holds %+v, want %+v", natives[0], want) + } +} + +func TestRecorderDiscardsDuplicates(t *testing.T) { + dc := models.MustNewDomainConfig("example.com") + rc := dc.MustNewRecordConfig("www", 300, "A", "192.0.2.1") + rc.Original = fakeNative{Name: "www", Type: "A"} + + rec := NewRecorder() + for range 3 { + rec.Observe(models.Records{rc}, models.Records{rc}) + } + + dir := t.TempDir() + if _, err := rec.WriteTo(dir, "example"); err != nil { + t.Fatalf("WriteTo() error: %v", err) + } + + records, err := os.ReadFile(filepath.Join(dir, "example.records")) + if err != nil { + t.Fatal(err) + } + if want := "www 300 IN A 192.0.2.1\n"; string(records) != want { + t.Errorf("example.records = %q, want %q", records, want) + } + + data, err := os.ReadFile(filepath.Join(dir, "example.json")) + if err != nil { + t.Fatal(err) + } + var natives []fakeNative + if err := json.Unmarshal(data, &natives); err != nil { + t.Fatal(err) + } + if len(natives) != 1 { + t.Errorf("example.json holds %d natives, want 1", len(natives)) + } +} + +func TestRecorderReportsANativeItCannotMarshal(t *testing.T) { + dc := models.MustNewDomainConfig("example.com") + rc := dc.MustNewRecordConfig("www", 300, "A", "192.0.2.1") + rc.Original = make(chan int) + + rec := NewRecorder() + rec.Observe(nil, models.Records{rc}) + + written, err := rec.WriteTo(t.TempDir(), "example") + if err == nil { + t.Error("WriteTo() returned no error for a native that cannot be marshalled") + } + if len(written) != 0 { + t.Errorf("WriteTo() wrote %v, want nothing", written) + } +} + +func TestRecorderWritesNothingWhenItObservedNothing(t *testing.T) { + dir := t.TempDir() + + written, err := NewRecorder().WriteTo(dir, "example") + if err != nil { + t.Fatalf("WriteTo() error: %v", err) + } + if len(written) != 0 { + t.Errorf("WriteTo() wrote %v, want nothing", written) + } + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Errorf("WriteTo() created %d files, want 0", len(entries)) + } +} + +func TestRecordObservesTheConversionsAndReturnsWhatTheProviderReturned(t *testing.T) { + dc := models.MustNewDomainConfig("example.com") + dc.Records = models.Records{dc.MustNewRecordConfig("www", 300, "A", "192.0.2.1")} + + existing := dc.MustNewRecordConfig("mail", 300, "A", "192.0.2.2") + existing.Original = fakeNative{Name: "mail", Type: "A"} + + wantErr := errors.New("provider failed") + fake := &fakeProvider{ + corrections: []*models.Correction{{Msg: "a correction"}}, + count: 7, + err: wantErr, + } + + rec := NewRecorder() + corrections, count, err := Record(fake, rec).GetZoneRecordsCorrections(dc, models.Records{existing}) + + if fake.calls != 1 { + t.Errorf("provider was called %d times, want 1", fake.calls) + } + if len(corrections) != 1 || corrections[0].Msg != "a correction" { + t.Errorf("corrections = %v, want the provider's own", corrections) + } + if count != 7 { + t.Errorf("count = %d, want 7", count) + } + if !errors.Is(err, wantErr) { + t.Errorf("err = %v, want %v", err, wantErr) + } + + dir := t.TempDir() + written, err := rec.WriteTo(dir, "example") + if err != nil { + t.Fatalf("WriteTo() error: %v", err) + } + want := []string{filepath.Join(dir, "example.records"), filepath.Join(dir, "example.json")} + if len(written) != len(want) || written[0] != want[0] || written[1] != want[1] { + t.Fatalf("WriteTo() wrote %v, want %v", written, want) + } + + records, err := os.ReadFile(written[0]) + if err != nil { + t.Fatal(err) + } + if want := "www 300 IN A 192.0.2.1\n"; string(records) != want { + t.Errorf("example.records = %q, want %q", records, want) + } +} + +func TestProviderNameIsThePackageTheProviderIsIn(t *testing.T) { + name, err := ProviderName(&fakeProvider{}) + if err != nil { + t.Fatalf("ProviderName() error: %v", err) + } + if name != "providergolden" { + t.Errorf("ProviderName() = %q, want %q", name, "providergolden") + } +} + +func TestARecordingIsNamedTheWayTheChecksReadIt(t *testing.T) { + name, err := ProviderName(&fakeProvider{}) + if err != nil { + t.Fatalf("ProviderName() error: %v", err) + } + + dc := models.MustNewDomainConfig("example.com") + rc := dc.MustNewRecordConfig("www", 300, "A", "192.0.2.1") + rc.Original = fakeNative{Name: "www", Type: "A"} + + rec := NewRecorder() + rec.Observe(models.Records{rc}, models.Records{rc}) + + packageDir := filepath.Join(t.TempDir(), name) + if _, err := rec.WriteTo(filepath.Join(packageDir, testdataDir), name); err != nil { + t.Fatalf("WriteTo() error: %v", err) + } + + t.Chdir(packageDir) + for _, ext := range []string{nativesExt, recordsExt} { + input, err := inputFile(ext) + if err != nil { + t.Fatalf("inputFile(%q) error: %v", ext, err) + } + if _, ok, err := loadInput(testdataDir, input); err != nil { + t.Fatalf("loadInput(%q) error: %v", input, err) + } else if !ok { + t.Errorf("the recording is not readable as %s", filepath.Join(testdataDir, input)) + } + } +} + +func TestTestdataDirIsTheProvidersOwnTestdataDirectory(t *testing.T) { + dir, err := TestdataDir(&fakeProvider{}) + if err != nil { + t.Fatalf("TestdataDir() error: %v", err) + } + if !filepath.IsAbs(dir) { + t.Errorf("TestdataDir() = %q, want an absolute path", dir) + } + if want := filepath.Join("providers", "providergolden", testdataDir); !strings.HasSuffix(dir, want) { + t.Errorf("TestdataDir() = %q, want a path ending in %q", dir, want) + } +} + +func TestResolveDirIsRelativeToTheModuleRoot(t *testing.T) { + rel := filepath.Join("providers", "bind", testdataDir) + + dir, err := ResolveDir(rel) + if err != nil { + t.Fatalf("ResolveDir(%q) error: %v", rel, err) + } + + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if dir == filepath.Join(wd, rel) { + t.Errorf("ResolveDir(%q) = %q, want a path under the module root, not the working directory", rel, dir) + } + + root := strings.TrimSuffix(dir, string(filepath.Separator)+rel) + if _, err := os.Stat(filepath.Join(root, "go.mod")); err != nil { + t.Errorf("ResolveDir(%q) = %q, want a path under the directory holding go.mod: %v", rel, dir, err) + } +} + +func TestResolveDirKeepsAnAbsolutePath(t *testing.T) { + want := t.TempDir() + + dir, err := ResolveDir(want) + if err != nil { + t.Fatalf("ResolveDir(%q) error: %v", want, err) + } + if dir != want { + t.Errorf("ResolveDir(%q) = %q, want %q", want, dir, want) + } +} diff --git a/providers/akamaiedgedns/akamaiEdgeDnsService.go b/providers/akamaiedgedns/akamaiEdgeDnsService.go index d1e35b6e91..f799b7ee4f 100644 --- a/providers/akamaiedgedns/akamaiEdgeDnsService.go +++ b/providers/akamaiedgedns/akamaiEdgeDnsService.go @@ -321,46 +321,57 @@ func (a *edgeDNSProvider) getRecords(ctx context.Context, dc *models.DomainConfi // For each AkamaiEdgeDNS recordset... for _, akarecset := range akaRecordsets { - akaname := akarecset.Name - akatype := akarecset.Type - akattl := akarecset.TTL - label := dc.LabelFromFQDNNoDot(akaname) - - // Don't report the existence of an SOA record (because DnsControl will try to delete the SOA record). - if akatype == "SOA" { - continue + recs, err := nativeToRecords(dc, akarecset) + if err != nil { + return nil, err } + recordConfigs = append(recordConfigs, recs...) + } + + return recordConfigs, nil +} + +// nativeToRecords converts an AkamaiEdgeDNS recordset into 1 or more +// RecordConfig structs. It returns nothing for an SOA recordset, whose existence +// is not reported (because DnsControl will try to delete the SOA record). +func nativeToRecords(dc *models.DomainConfig, akarecset dns.RecordSet) ([]*models.RecordConfig, error) { + akaname := akarecset.Name + akatype := akarecset.Type + akattl := akarecset.TTL + label := dc.LabelFromFQDNNoDot(akaname) + + if akatype == "SOA" { + return nil, nil + } - // AKAMAITLC has 2 rdata entries that form 1 logical record: [answerType, target] - if akatype == "AKAMAITLC" { - combined := strings.Join(akarecset.Rdata, " ") - parts := strings.Fields(combined) - if len(parts) != 2 { - return nil, fmt.Errorf("AKAMAITLC rdata must contain 2 fields, got: %v", akarecset.Rdata) - } - rc, err := dc.NewRecordConfig(label, uint32(akattl), privatetypes.TypeAKAMAITLC, parts[0], parts[1]) - if err != nil { - return nil, err - } - rc.Metadata = map[string]string{"akamai_raw_rdata": combined} - recordConfigs = append(recordConfigs, rc) - continue + // AKAMAITLC has 2 rdata entries that form 1 logical record: [answerType, target] + if akatype == "AKAMAITLC" { + combined := strings.Join(akarecset.Rdata, " ") + parts := strings.Fields(combined) + if len(parts) != 2 { + return nil, fmt.Errorf("AKAMAITLC rdata must contain 2 fields, got: %v", akarecset.Rdata) + } + rc, err := dc.NewRecordConfig(label, uint32(akattl), privatetypes.TypeAKAMAITLC, parts[0], parts[1]) + if err != nil { + return nil, err } + rc.Metadata = map[string]string{"akamai_raw_rdata": combined} + return []*models.RecordConfig{rc}, nil + } - // ... convert the recordset into 1 or more RecordConfig structs - for _, r := range akarecset.Rdata { - data := r - if akatype == "LOC" { - data = fixLocAltitude(r) - } - rc, err := dc.NewRecordConfigParse(label, uint32(akattl), akatype, data) - if err != nil { - return nil, err - } - - rc.Metadata = map[string]string{"akamai_raw_rdata": r} - recordConfigs = append(recordConfigs, rc) + var recordConfigs []*models.RecordConfig + for _, r := range akarecset.Rdata { + data := r + if akatype == "LOC" { + data = fixLocAltitude(r) } + rc, err := dc.NewRecordConfigParse(label, uint32(akattl), akatype, data) + if err != nil { + return nil, err + } + + rc.Metadata = map[string]string{"akamai_raw_rdata": r} + recordConfigs = append(recordConfigs, rc) } return recordConfigs, nil diff --git a/providers/azuredns/convert_golden_test.go b/providers/azuredns/convert_golden_test.go new file mode 100644 index 0000000000..70a60d1256 --- /dev/null +++ b/providers/azuredns/convert_golden_test.go @@ -0,0 +1,18 @@ +package azuredns + +import ( + "testing" + + adns "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns" + "github.com/DNSControl/dnscontrol/v5/models" + "github.com/DNSControl/dnscontrol/v5/pkg/providergolden" +) + +var testDomain = providergolden.Domain("AZURE_DNS") + +func TestNativeToRecordsGolden(t *testing.T) { + providergolden.CheckToRC(t, "azuredns_nativetorecords", testDomain, + func(dc *models.DomainConfig, native adns.RecordSet) ([]*models.RecordConfig, error) { + return nativeToRecords(&native, dc), nil + }) +} diff --git a/providers/azureprivatedns/convert_golden_test.go b/providers/azureprivatedns/convert_golden_test.go new file mode 100644 index 0000000000..052dcc1a35 --- /dev/null +++ b/providers/azureprivatedns/convert_golden_test.go @@ -0,0 +1,18 @@ +package azureprivatedns + +import ( + "testing" + + adns "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns" + "github.com/DNSControl/dnscontrol/v5/models" + "github.com/DNSControl/dnscontrol/v5/pkg/providergolden" +) + +var testDomain = providergolden.Domain("AZURE_PRIVATE_DNS") + +func TestNativeToRecordsGolden(t *testing.T) { + providergolden.CheckToRC(t, "azureprivatedns_nativetorecords", testDomain, + func(dc *models.DomainConfig, native adns.RecordSet) ([]*models.RecordConfig, error) { + return nativeToRecords(&native, dc), nil + }) +} diff --git a/providers/cloudflare/convert_golden_test.go b/providers/cloudflare/convert_golden_test.go new file mode 100644 index 0000000000..cc9fa9ff5f --- /dev/null +++ b/providers/cloudflare/convert_golden_test.go @@ -0,0 +1,20 @@ +package cloudflare + +import ( + "testing" + + "github.com/DNSControl/dnscontrol/v5/models" + "github.com/DNSControl/dnscontrol/v5/pkg/providergolden" + "github.com/cloudflare/cloudflare-go" +) + +var testDomain = providergolden.Domain("CLOUDFLAREAPI") + +func TestNativeToRecordGolden(t *testing.T) { + c := &cloudflareProvider{} + providergolden.CheckToRC(t, "cloudflare_nativetorecord", testDomain, + func(dc *models.DomainConfig, native cloudflare.DNSRecord) ([]*models.RecordConfig, error) { + rc, err := c.nativeToRecord(dc, native) + return []*models.RecordConfig{rc}, err + }) +} diff --git a/providers/cloudns/convert_golden_test.go b/providers/cloudns/convert_golden_test.go new file mode 100644 index 0000000000..d7e2ede0d4 --- /dev/null +++ b/providers/cloudns/convert_golden_test.go @@ -0,0 +1,22 @@ +package cloudns + +import ( + "testing" + + "github.com/DNSControl/dnscontrol/v5/models" + "github.com/DNSControl/dnscontrol/v5/pkg/providergolden" +) + +var testDomain = providergolden.Domain("CLOUDNS") + +func TestToRcGolden(t *testing.T) { + providergolden.CheckToRC(t, "cloudns_torc", testDomain, + func(dc *models.DomainConfig, native domainRecord) ([]*models.RecordConfig, error) { + rc, err := toRc(dc, &native) + return []*models.RecordConfig{rc}, err + }) +} + +func TestToReqGolden(t *testing.T) { + providergolden.CheckToNative(t, "cloudns_toreq", testDomain, toReq) +} diff --git a/providers/cnr/convert_golden_test.go b/providers/cnr/convert_golden_test.go new file mode 100644 index 0000000000..43fdd9f146 --- /dev/null +++ b/providers/cnr/convert_golden_test.go @@ -0,0 +1,17 @@ +package cnr + +import ( + "testing" + + "github.com/DNSControl/dnscontrol/v5/models" + "github.com/DNSControl/dnscontrol/v5/pkg/providergolden" +) + +var testDomain = providergolden.Domain("CNR") + +func TestCreateRecordStringGolden(t *testing.T) { + providergolden.CheckToNative(t, "cnr_createrecordstring", testDomain, + func(rc *models.RecordConfig) (string, error) { + return (&Client{}).createRecordString(rc, testDomain) + }) +} diff --git a/providers/digitalocean/convert_golden_test.go b/providers/digitalocean/convert_golden_test.go new file mode 100644 index 0000000000..101f73c87c --- /dev/null +++ b/providers/digitalocean/convert_golden_test.go @@ -0,0 +1,26 @@ +package digitalocean + +import ( + "testing" + + "github.com/DNSControl/dnscontrol/v5/models" + "github.com/DNSControl/dnscontrol/v5/pkg/providergolden" + "github.com/digitalocean/godo" +) + +var testDomain = providergolden.Domain("DIGITALOCEAN") + +func TestToRcGolden(t *testing.T) { + providergolden.CheckToRC(t, "digitalocean_torc", testDomain, + func(dc *models.DomainConfig, native godo.DomainRecord) ([]*models.RecordConfig, error) { + rc, err := toRc(dc, &native) + return []*models.RecordConfig{rc}, err + }) +} + +func TestToReqGolden(t *testing.T) { + providergolden.CheckToNative(t, "digitalocean_toreq", testDomain, + func(rc *models.RecordConfig) (*godo.DomainRecordEditRequest, error) { + return toReq(rc), nil + }) +} diff --git a/providers/gandiv5/convert_golden_test.go b/providers/gandiv5/convert_golden_test.go new file mode 100644 index 0000000000..0e73a5ec7e --- /dev/null +++ b/providers/gandiv5/convert_golden_test.go @@ -0,0 +1,13 @@ +package gandiv5 + +import ( + "testing" + + "github.com/DNSControl/dnscontrol/v5/pkg/providergolden" +) + +var testDomain = providergolden.Domain("GANDI_V5") + +func TestNativeToRecordsGolden(t *testing.T) { + providergolden.CheckToRC(t, "gandiv5_nativetorecords", testDomain, nativeToRecords) +} diff --git a/providers/gcloud/convert_golden_test.go b/providers/gcloud/convert_golden_test.go new file mode 100644 index 0000000000..d731792af8 --- /dev/null +++ b/providers/gcloud/convert_golden_test.go @@ -0,0 +1,27 @@ +package gcloud + +import ( + "testing" + + "github.com/DNSControl/dnscontrol/v5/models" + "github.com/DNSControl/dnscontrol/v5/pkg/providergolden" + gdns "google.golang.org/api/dns/v1" +) + +var testDomain = providergolden.Domain("GCLOUD") + +func TestNativeToRecordGolden(t *testing.T) { + providergolden.CheckToRC(t, "gcloud_nativetorecord", testDomain, + func(dc *models.DomainConfig, native gdns.ResourceRecordSet) ([]*models.RecordConfig, error) { + // GCLOUD returns every value of a label/rtype pair in one set. + rcs := make([]*models.RecordConfig, 0, len(native.Rrdatas)) + for _, rdata := range native.Rrdatas { + rc, err := nativeToRecord(&native, rdata, dc) + if err != nil { + return nil, err + } + rcs = append(rcs, rc) + } + return rcs, nil + }) +} diff --git a/providers/hedns/convert_golden_test.go b/providers/hedns/convert_golden_test.go new file mode 100644 index 0000000000..2d6f787dc8 --- /dev/null +++ b/providers/hedns/convert_golden_test.go @@ -0,0 +1,18 @@ +package hedns + +import ( + "testing" + + "github.com/DNSControl/dnscontrol/v5/models" + "github.com/DNSControl/dnscontrol/v5/pkg/providergolden" +) + +var testDomain = providergolden.Domain("HEDNS") + +func TestRecordToRCGolden(t *testing.T) { + providergolden.CheckToRC(t, "hedns_recordtorc", testDomain, + func(dc *models.DomainConfig, native Record) ([]*models.RecordConfig, error) { + rc, err := recordToRC(dc, native) + return []*models.RecordConfig{rc}, err + }) +} diff --git a/providers/hetznerv2/hetznerv2Provider.go b/providers/hetznerv2/hetznerv2Provider.go index fe30cd39e7..be1728780c 100644 --- a/providers/hetznerv2/hetznerv2Provider.go +++ b/providers/hetznerv2/hetznerv2Provider.go @@ -233,27 +233,38 @@ func (h *hetznerv2Provider) GetZoneRecords(dc *models.DomainConfig) (models.Reco } existingRecords := make(models.Records, 0, len(records)) for _, rrSet := range records { - if rrSet.Type == hcloud.ZoneRRSetTypeSOA { - // SOA records are not available for editing, hide them. - continue - } - var ttl uint32 - if rrSet.TTL != nil { - ttl = uint32(*rrSet.TTL) - } else { - ttl = uint32(z.TTL) + recs, err := nativeToRecords(dc, rrSet, uint32(z.TTL)) + if err != nil { + return nil, err } + existingRecords = append(existingRecords, recs...) + } + return existingRecords, nil +} - for _, r := range rrSet.Records { - rc, err := dc.NewRecordConfigParse(dc.LabelFromShort(rrSet.Name), ttl, string(rrSet.Type), r.Value) - if err != nil { - return nil, err - } - rc.Original = rrSet - existingRecords = append(existingRecords, rc) +// nativeToRecords converts a Hetzner RRSet to RecordConfigs, one per value. +// zoneTTL is the TTL of the zone the RRSet belongs to, used when the RRSet does +// not carry one of its own. It returns nothing for SOA RRSets, which are hidden. +func nativeToRecords(dc *models.DomainConfig, rrSet *hcloud.ZoneRRSet, zoneTTL uint32) (models.Records, error) { + if rrSet.Type == hcloud.ZoneRRSetTypeSOA { + // SOA records are not available for editing, hide them. + return nil, nil + } + ttl := zoneTTL + if rrSet.TTL != nil { + ttl = uint32(*rrSet.TTL) + } + + recs := make(models.Records, 0, len(rrSet.Records)) + for _, r := range rrSet.Records { + rc, err := dc.NewRecordConfigParse(dc.LabelFromShort(rrSet.Name), ttl, string(rrSet.Type), r.Value) + if err != nil { + return nil, err } + rc.Original = rrSet + recs = append(recs, rc) } - return existingRecords, nil + return recs, nil } // ListZones lists the zones on this account. diff --git a/providers/inwx/inwxProvider.go b/providers/inwx/inwxProvider.go index 6440c8f553..77376c2fe7 100644 --- a/providers/inwx/inwxProvider.go +++ b/providers/inwx/inwxProvider.go @@ -420,58 +420,73 @@ func (api *inwxAPI) GetZoneRecords(dc *models.DomainConfig) (models.Records, err records := models.Records{} for _, record := range info.Records { - if record.Type == "SOA" { + rc, err := toRecordConfig(dc, record) + if err != nil { + return nil, err + } + if rc == nil { continue } - /* - INWX is a little bit special for CNAME,NS,MX and SRV records: - The API will not accept any target with a final dot but will - instead always add this final dot internally. - Records with empty targets (i.e. records with target ".") - are allowed. - */ - rtypeAddDot := map[string]bool{ - "ALIAS": true, - "CNAME": true, - "MX": true, - "NS": true, - "SRV": true, - "PTR": true, - } - if rtypeAddDot[record.Type] { - if record.Type == "MX" && record.Content == "." { - // null records don't need to be modified - } else if record.Type == "SRV" && strings.HasSuffix(record.Content, ".") { - // null targets don't need to be modified - } else { - record.Content = record.Content + "." - } - } + records = append(records, rc) + } - label := dc.ToShort(record.Name) - ttl := uint32(record.TTL) + return records, nil +} - var rc *models.RecordConfig - switch rType := record.Type; rType { - case "MX": - rc, err = dc.NewRecordConfig(label, ttl, rType, record.Priority, record.Content) - case "SRV": - rc, err = dc.NewRecordConfig(label, ttl, rType, record.Priority, record.Content, - nrc.Flags{SrvWeirdSplit: true}) - default: - rc, err = dc.NewRecordConfigParse(label, ttl, rType, record.Content, - nrc.Flags{TxtDontParse: true}) - } - if err != nil { - return nil, fmt.Errorf("INWX: unparsable record received: %w", err) +// toRecordConfig converts an INWX record to a RecordConfig. It returns nil for +// SOA records, which are not managed. +func toRecordConfig(dc *models.DomainConfig, record goinwx.NameserverRecord) (*models.RecordConfig, error) { + if record.Type == "SOA" { + return nil, nil + } + + /* + INWX is a little bit special for CNAME,NS,MX and SRV records: + The API will not accept any target with a final dot but will + instead always add this final dot internally. + Records with empty targets (i.e. records with target ".") + are allowed. + */ + rtypeAddDot := map[string]bool{ + "ALIAS": true, + "CNAME": true, + "MX": true, + "NS": true, + "SRV": true, + "PTR": true, + } + if rtypeAddDot[record.Type] { + if record.Type == "MX" && record.Content == "." { + // null records don't need to be modified + } else if record.Type == "SRV" && strings.HasSuffix(record.Content, ".") { + // null targets don't need to be modified + } else { + record.Content = record.Content + "." } - rc.Original = record + } - records = append(records, rc) + label := dc.ToShort(record.Name) + ttl := uint32(record.TTL) + + var rc *models.RecordConfig + var err error + switch rType := record.Type; rType { + case "MX": + rc, err = dc.NewRecordConfig(label, ttl, rType, record.Priority, record.Content) + case "SRV": + rc, err = dc.NewRecordConfig(label, ttl, rType, record.Priority, record.Content, + nrc.Flags{SrvWeirdSplit: true}) + default: + rc, err = dc.NewRecordConfigParse(label, ttl, rType, record.Content, + nrc.Flags{TxtDontParse: true}) } + if err != nil { + return nil, fmt.Errorf("INWX: unparsable record received: %w", err) + } + rc.Original = record - return records, nil + return rc, nil } // ListZones returns the zones configured in INWX. diff --git a/providers/luadns/convert_golden_test.go b/providers/luadns/convert_golden_test.go new file mode 100644 index 0000000000..62cc40d246 --- /dev/null +++ b/providers/luadns/convert_golden_test.go @@ -0,0 +1,27 @@ +package luadns + +import ( + "testing" + + api "github.com/luadns/luadns-go" + + "github.com/DNSControl/dnscontrol/v5/models" + "github.com/DNSControl/dnscontrol/v5/pkg/providergolden" +) + +var testDomain = providergolden.Domain("LUADNS") + +func TestNativeToRecordGolden(t *testing.T) { + providergolden.CheckToRC(t, "luadns_nativetorecord", testDomain, + func(dc *models.DomainConfig, native api.Record) ([]*models.RecordConfig, error) { + rc, err := nativeToRecord(dc, &native) + return []*models.RecordConfig{rc}, err + }) +} + +func TestRecordsToNativeGolden(t *testing.T) { + providergolden.CheckToNative(t, "luadns_recordstonative", testDomain, + func(rc *models.RecordConfig) (*api.RR, error) { + return recordsToNative([]*models.RecordConfig{rc})[0], nil + }) +} diff --git a/providers/namedotcom/convert_golden_test.go b/providers/namedotcom/convert_golden_test.go new file mode 100644 index 0000000000..1e73883e95 --- /dev/null +++ b/providers/namedotcom/convert_golden_test.go @@ -0,0 +1,19 @@ +package namedotcom + +import ( + "testing" + + "github.com/DNSControl/dnscontrol/v5/models" + "github.com/DNSControl/dnscontrol/v5/pkg/providergolden" + "github.com/namedotcom/go/namecom" +) + +var testDomain = providergolden.Domain("NAMEDOTCOM") + +func TestToRecordGolden(t *testing.T) { + providergolden.CheckToRC(t, "namedotcom_torecord", testDomain, + func(dc *models.DomainConfig, native namecom.Record) ([]*models.RecordConfig, error) { + rc, err := toRecord(&native, dc) + return []*models.RecordConfig{rc}, err + }) +} diff --git a/providers/netlify/convert_golden_test.go b/providers/netlify/convert_golden_test.go new file mode 100644 index 0000000000..9aa45a4351 --- /dev/null +++ b/providers/netlify/convert_golden_test.go @@ -0,0 +1,25 @@ +package netlify + +import ( + "testing" + + "github.com/DNSControl/dnscontrol/v5/models" + "github.com/DNSControl/dnscontrol/v5/pkg/providergolden" +) + +var testDomain = providergolden.Domain("NETLIFY") + +func TestToRecordConfigGolden(t *testing.T) { + providergolden.CheckToRC(t, "netlify_torecordconfig", testDomain, + func(dc *models.DomainConfig, native dnsRecord) ([]*models.RecordConfig, error) { + rc, err := toRecordConfig(dc, &native) + return []*models.RecordConfig{rc}, err + }) +} + +func TestToReqGolden(t *testing.T) { + providergolden.CheckToNative(t, "netlify_toreq", testDomain, + func(rc *models.RecordConfig) (*dnsRecordCreate, error) { + return toReq(rc), nil + }) +} diff --git a/providers/netlify/netlifyProvider.go b/providers/netlify/netlifyProvider.go index 0ed25991fc..cf1697adf0 100644 --- a/providers/netlify/netlifyProvider.go +++ b/providers/netlify/netlifyProvider.go @@ -104,44 +104,60 @@ func (n *netlifyProvider) GetZoneRecords(dc *models.DomainConfig) (models.Record cleanRecords := make(models.Records, 0) for _, r := range records { - if r.Type == "SOA" { - continue + rec, err := toRecordConfig(dc, r) + if err != nil { + return nil, err } - - label := dc.LabelFromFQDNNoDot(r.Hostname) // Netlify returns the FQDN. - ttl := uint32(r.TTL) - - var rec *models.RecordConfig - switch rtype := r.Type; rtype { - case "NETLIFY", "NETLIFYv6": // transparently ignore + if rec == nil { continue - case "MX": - rec, err = dc.NewRecordConfig(label, ttl, dnsv2.TypeMX, r.Priority, r.Value, - nrc.Flags{TargetIsFqdnNoDot: true}) - case "SRV": - rec, err = dc.NewRecordConfig(label, ttl, dnsv2.TypeSRV, r.Priority, r.Weight, r.Port, r.Value, - nrc.Flags{TargetIsFqdnNoDot: true}) - case "TXT": - rec, err = dc.NewRecordConfig(label, ttl, dnsv2.TypeTXT, r.Value) - case "CAA": - rec, err = dc.NewRecordConfig(label, ttl, dnsv2.TypeCAA, r.Flag, r.Tag, r.Value, - nrc.Flags{TargetIsFqdnNoDot: true}) - default: - rec, err = dc.NewRecordConfigParse(label, ttl, r.Type, r.Value, - nrc.Flags{TargetIsFqdnNoDot: true}) - } - if err != nil { - return nil, fmt.Errorf("unparsable record received from Netlify: %w", err) } - rec.Original = r - cleanRecords = append(cleanRecords, rec) } return cleanRecords, nil } +// toRecordConfig converts a Netlify record to a RecordConfig. It returns nil for +// SOA records and for the NETLIFY and NETLIFYv6 pseudo-types, which are ignored. +func toRecordConfig(dc *models.DomainConfig, r *dnsRecord) (*models.RecordConfig, error) { + if r.Type == "SOA" { + return nil, nil + } + + label := dc.LabelFromFQDNNoDot(r.Hostname) // Netlify returns the FQDN. + ttl := uint32(r.TTL) + + var rec *models.RecordConfig + var err error + switch rtype := r.Type; rtype { + case "NETLIFY", "NETLIFYv6": // transparently ignore + return nil, nil + case "MX": + rec, err = dc.NewRecordConfig(label, ttl, dnsv2.TypeMX, r.Priority, r.Value, + nrc.Flags{TargetIsFqdnNoDot: true}) + case "SRV": + rec, err = dc.NewRecordConfig(label, ttl, dnsv2.TypeSRV, r.Priority, r.Weight, r.Port, r.Value, + nrc.Flags{TargetIsFqdnNoDot: true}) + case "TXT": + rec, err = dc.NewRecordConfig(label, ttl, dnsv2.TypeTXT, r.Value) + case "CAA": + rec, err = dc.NewRecordConfig(label, ttl, dnsv2.TypeCAA, r.Flag, r.Tag, r.Value, + nrc.Flags{TargetIsFqdnNoDot: true}) + default: + rec, err = dc.NewRecordConfigParse(label, ttl, r.Type, r.Value, + nrc.Flags{TargetIsFqdnNoDot: true}) + } + + if err != nil { + return nil, fmt.Errorf("unparsable record received from Netlify: %w", err) + } + + rec.Original = r + + return rec, nil +} + // ListZones returns all DNS zones managed by this provider. func (n *netlifyProvider) ListZones() ([]string, error) { zones, err := n.getDNSZones() diff --git a/providers/ns1/convert_golden_test.go b/providers/ns1/convert_golden_test.go new file mode 100644 index 0000000000..564991722e --- /dev/null +++ b/providers/ns1/convert_golden_test.go @@ -0,0 +1,18 @@ +package ns1 + +import ( + "testing" + + "github.com/DNSControl/dnscontrol/v5/models" + "github.com/DNSControl/dnscontrol/v5/pkg/providergolden" + "gopkg.in/ns1/ns1-go.v2/rest/model/dns" +) + +var testDomain = providergolden.Domain("NS1") + +func TestConvertGolden(t *testing.T) { + providergolden.CheckToRC(t, "ns1_convert", testDomain, + func(dc *models.DomainConfig, native dns.ZoneRecord) ([]*models.RecordConfig, error) { + return convert(&native, dc) + }) +} diff --git a/providers/oracle/oracleProvider.go b/providers/oracle/oracleProvider.go index f9229f12c0..3af06d2ad1 100644 --- a/providers/oracle/oracleProvider.go +++ b/providers/oracle/oracleProvider.go @@ -208,26 +208,13 @@ func (o *oracleProvider) GetZoneRecords(dc *models.DomainConfig) (models.Records } for _, record := range getResp.Items { - // Hide SOAs - if *record.Rtype == "SOA" { - continue - } - - label := dc.LabelFromFQDNNoDot(*record.Domain) - ttl := uint32(*record.Ttl) - var rc *models.RecordConfig - - switch *record.Rtype { - case "ALIAS": - rc, err = dc.NewRecordConfig(label, ttl, *record.Rtype, *record.Rdata) - default: - rc, err = dc.NewRecordConfigParse(label, ttl, *record.Rtype, *record.Rdata) - } - + rc, err := toRecordConfig(dc, record) if err != nil { return nil, err } - rc.Original = record + if rc == nil { + continue + } records = append(records, rc) } @@ -242,6 +229,35 @@ func (o *oracleProvider) GetZoneRecords(dc *models.DomainConfig) (models.Records return records, nil } +// toRecordConfig converts an Oracle record to a RecordConfig. It returns nil for +// SOA records, which are hidden. +func toRecordConfig(dc *models.DomainConfig, record dns.Record) (*models.RecordConfig, error) { + // Hide SOAs + if *record.Rtype == "SOA" { + return nil, nil + } + + label := dc.LabelFromFQDNNoDot(*record.Domain) + ttl := uint32(*record.Ttl) + + var rc *models.RecordConfig + var err error + + switch *record.Rtype { + case "ALIAS": + rc, err = dc.NewRecordConfig(label, ttl, *record.Rtype, *record.Rdata) + default: + rc, err = dc.NewRecordConfigParse(label, ttl, *record.Rtype, *record.Rdata) + } + + if err != nil { + return nil, err + } + rc.Original = record + + return rc, nil +} + // GetZoneRecordsCorrections returns a list of corrections that will turn existing records into dc.Records. func (o *oracleProvider) GetZoneRecordsCorrections(dc *models.DomainConfig, existingRecords models.Records) ([]*models.Correction, int, error) { var err error diff --git a/providers/packetframe/convert_golden_test.go b/providers/packetframe/convert_golden_test.go new file mode 100644 index 0000000000..a6ed7e6a94 --- /dev/null +++ b/providers/packetframe/convert_golden_test.go @@ -0,0 +1,23 @@ +package packetframe + +import ( + "testing" + + "github.com/DNSControl/dnscontrol/v5/models" + "github.com/DNSControl/dnscontrol/v5/pkg/providergolden" +) + +func TestToRcGolden(t *testing.T) { + providergolden.CheckToRC(t, "packetframe_torc", "example.com", + func(dc *models.DomainConfig, native domainRecord) ([]*models.RecordConfig, error) { + rc, err := toRc(dc, &native) + return []*models.RecordConfig{rc}, err + }) +} + +func TestToReqGolden(t *testing.T) { + providergolden.CheckToNative(t, "packetframe_toreq", "example.com", + func(rc *models.RecordConfig) (*domainRecord, error) { + return toReq("zone-1", rc) + }) +} diff --git a/providers/packetframe/testdata/packetframe.json b/providers/packetframe/testdata/packetframe.json new file mode 100644 index 0000000000..a4bedc8d53 --- /dev/null +++ b/providers/packetframe/testdata/packetframe.json @@ -0,0 +1,83 @@ +[ + { + "id": "rec-1", + "label": "example.com.", + "proxy": false, + "ttl": 3600, + "type": "A", + "value": "192.0.2.1", + "zone": "zone-1" + }, + { + "id": "rec-2", + "label": "www.example.com.", + "proxy": false, + "ttl": 300, + "type": "A", + "value": "192.0.2.2", + "zone": "zone-1" + }, + { + "id": "rec-3", + "label": "www.example.com.", + "proxy": false, + "ttl": 300, + "type": "AAAA", + "value": "2001:db8::1", + "zone": "zone-1" + }, + { + "id": "rec-4", + "label": "alias.example.com.", + "proxy": false, + "ttl": 300, + "type": "CNAME", + "value": "www.example.com.", + "zone": "zone-1" + }, + { + "id": "rec-5", + "label": "example.com.", + "proxy": false, + "ttl": 3600, + "type": "MX", + "value": "10 mail.example.com.", + "zone": "zone-1" + }, + { + "id": "rec-6", + "label": "example.com.", + "proxy": false, + "ttl": 3600, + "type": "TXT", + "value": "v=spf1 include:_spf.example.net -all", + "zone": "zone-1" + }, + { + "id": "rec-7", + "label": "sub.example.com.", + "proxy": false, + "ttl": 86400, + "type": "NS", + "value": "ns1.example.net.", + "zone": "zone-1" + }, + { + "id": "rec-8", + "label": "_sip._tcp.example.com.", + "proxy": false, + "ttl": 300, + "type": "SRV", + "value": "10 20 5060 sip.example.com.", + "zone": "zone-1" + }, + { + "id": "rec-9", + "label": "example.com.", + "proxy": false, + "ttl": 3600, + "type": "CAA", + "value": "0 issue \"letsencrypt.org\"", + "zone": "zone-1" + } +] diff --git a/providers/packetframe/testdata/packetframe.records b/providers/packetframe/testdata/packetframe.records new file mode 100644 index 0000000000..0abf6dcc82 --- /dev/null +++ b/providers/packetframe/testdata/packetframe.records @@ -0,0 +1,9 @@ +@ 3600 IN A 192.0.2.1 +www 300 IN A 192.0.2.2 +www 300 IN AAAA 2001:db8::1 +alias 300 IN CNAME www.example.com. +@ 3600 IN MX 10 mail.example.com. +@ 3600 IN TXT "v=spf1 include:_spf.example.net -all" +sub 86400 IN NS ns1.example.net. +_sip._tcp 300 IN SRV 10 20 5060 sip.example.com. +@ 3600 IN CAA 0 issue "letsencrypt.org" diff --git a/providers/packetframe/testdata/packetframe_torc.golden b/providers/packetframe/testdata/packetframe_torc.golden new file mode 100644 index 0000000000..0abf6dcc82 --- /dev/null +++ b/providers/packetframe/testdata/packetframe_torc.golden @@ -0,0 +1,9 @@ +@ 3600 IN A 192.0.2.1 +www 300 IN A 192.0.2.2 +www 300 IN AAAA 2001:db8::1 +alias 300 IN CNAME www.example.com. +@ 3600 IN MX 10 mail.example.com. +@ 3600 IN TXT "v=spf1 include:_spf.example.net -all" +sub 86400 IN NS ns1.example.net. +_sip._tcp 300 IN SRV 10 20 5060 sip.example.com. +@ 3600 IN CAA 0 issue "letsencrypt.org" diff --git a/providers/packetframe/testdata/packetframe_toreq.golden b/providers/packetframe/testdata/packetframe_toreq.golden new file mode 100644 index 0000000000..bd1686a3b7 --- /dev/null +++ b/providers/packetframe/testdata/packetframe_toreq.golden @@ -0,0 +1,83 @@ +[ + { + "id": "", + "type": "A", + "label": "@", + "value": "192.0.2.1", + "ttl": 3600, + "proxy": false, + "zone": "zone-1" + }, + { + "id": "", + "type": "A", + "label": "www", + "value": "192.0.2.2", + "ttl": 300, + "proxy": false, + "zone": "zone-1" + }, + { + "id": "", + "type": "AAAA", + "label": "www", + "value": "2001:db8::1", + "ttl": 300, + "proxy": false, + "zone": "zone-1" + }, + { + "id": "", + "type": "CNAME", + "label": "alias", + "value": "www.example.com.", + "ttl": 300, + "proxy": false, + "zone": "zone-1" + }, + { + "id": "", + "type": "MX", + "label": "@", + "value": "10 mail.example.com.", + "ttl": 3600, + "proxy": false, + "zone": "zone-1" + }, + { + "id": "", + "type": "TXT", + "label": "@", + "value": "v=spf1 include:_spf.example.net -all", + "ttl": 3600, + "proxy": false, + "zone": "zone-1" + }, + { + "id": "", + "type": "NS", + "label": "sub", + "value": "ns1.example.net.", + "ttl": 86400, + "proxy": false, + "zone": "zone-1" + }, + { + "id": "", + "type": "SRV", + "label": "_sip._tcp", + "value": "10 20 5060 sip.example.com.", + "ttl": 300, + "proxy": false, + "zone": "zone-1" + }, + { + "id": "", + "type": "CAA", + "label": "@", + "value": "0 issue \"letsencrypt.org\"", + "ttl": 3600, + "proxy": false, + "zone": "zone-1" + } +] diff --git a/providers/porkbun/convert_golden_test.go b/providers/porkbun/convert_golden_test.go new file mode 100644 index 0000000000..41eb5adaa6 --- /dev/null +++ b/providers/porkbun/convert_golden_test.go @@ -0,0 +1,20 @@ +package porkbun + +import ( + "testing" + + "github.com/DNSControl/dnscontrol/v5/models" + "github.com/DNSControl/dnscontrol/v5/pkg/providergolden" +) + +func TestToRcGolden(t *testing.T) { + providergolden.CheckToRC(t, "porkbun_torc", "example.com", + func(dc *models.DomainConfig, native domainRecord) ([]*models.RecordConfig, error) { + rc, err := toRc(dc, &native) + return []*models.RecordConfig{rc}, err + }) +} + +func TestToReqGolden(t *testing.T) { + providergolden.CheckToNative(t, "porkbun_toreq", "example.com", toReq) +} diff --git a/providers/porkbun/testdata/porkbun.json b/providers/porkbun/testdata/porkbun.json new file mode 100644 index 0000000000..90a6a83c78 --- /dev/null +++ b/providers/porkbun/testdata/porkbun.json @@ -0,0 +1,106 @@ +[ + { + "content": "192.0.2.1", + "id": "201", + "name": "example.com", + "prio": "0", + "ttl": "600", + "type": "A" + }, + { + "content": "192.0.2.2", + "id": "202", + "name": "www.example.com", + "prio": "0", + "ttl": "600", + "type": "A" + }, + { + "content": "2001:db8::1", + "id": "203", + "name": "www.example.com", + "prio": "0", + "ttl": "600", + "type": "AAAA" + }, + { + "content": "www.example.com", + "id": "204", + "name": "alias.example.com", + "prio": "0", + "ttl": "600", + "type": "CNAME" + }, + { + "content": "mail.example.com", + "id": "205", + "name": "example.com", + "prio": "10", + "ttl": "3600", + "type": "MX" + }, + { + "content": "v=spf1 include:_spf.example.net -all", + "id": "206", + "name": "example.com", + "prio": "0", + "ttl": "600", + "type": "TXT" + }, + { + "content": "ns1.example.net", + "id": "207", + "name": "sub.example.com", + "prio": "0", + "ttl": "86400", + "type": "NS" + }, + { + "content": "0 issue \"letsencrypt.org\"", + "id": "208", + "name": "example.com", + "prio": "0", + "ttl": "600", + "type": "CAA" + }, + { + "content": "20 5060 sip.example.com.", + "id": "209", + "name": "_sip._tcp.example.com", + "prio": "10", + "ttl": "600", + "type": "SRV" + }, + { + "content": "3 1 1 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "id": "210", + "name": "_25._tcp.mail.example.com", + "prio": "0", + "ttl": "600", + "type": "TLSA" + }, + { + "content": "1 2 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "id": "211", + "name": "example.com", + "prio": "0", + "ttl": "600", + "type": "SSHFP" + }, + { + "content": "1 . alpn=h3,h2 ipv4hint=192.0.2.1", + "id": "212", + "name": "example.com", + "prio": "0", + "ttl": "600", + "type": "HTTPS" + }, + { + "content": "16 svc.example.net. port=8443 alpn=h2", + "id": "213", + "name": "_8443._foo.example.com", + "prio": "0", + "ttl": "600", + "type": "SVCB" + } +] diff --git a/providers/porkbun/testdata/porkbun.records b/providers/porkbun/testdata/porkbun.records new file mode 100644 index 0000000000..d2da6b2109 --- /dev/null +++ b/providers/porkbun/testdata/porkbun.records @@ -0,0 +1,15 @@ +@ 600 IN A 192.0.2.1 +www 600 IN A 192.0.2.2 +www 600 IN AAAA 2001:db8::1 +alias 600 IN CNAME www.example.com. +@ 3600 IN MX 10 mail.example.com. +@ 600 IN TXT "v=spf1 include:_spf.example.net -all" +sub 86400 IN NS ns1.example.net. +@ 600 IN CAA 0 issue "letsencrypt.org" +_sip._tcp 600 IN SRV 10 20 5060 sip.example.com. +_25._tcp.mail 600 IN TLSA 3 1 1 0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF +@ 600 IN SSHFP 1 2 0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF +@ 600 IN HTTPS 1 . alpn="h3,h2" ipv4hint="192.0.2.1" +_8443._foo 600 IN SVCB 16 svc.example.net. port="8443" alpn="h2" +fwd 0 IN URL https://example.net/landing ; includePath="no" type="temporary" wildcard="no" +perm 0 IN URL301 https://example.net/moved ; includePath="yes" type="permanent" wildcard="yes" diff --git a/providers/porkbun/testdata/porkbun_torc.golden b/providers/porkbun/testdata/porkbun_torc.golden new file mode 100644 index 0000000000..22e2733a92 --- /dev/null +++ b/providers/porkbun/testdata/porkbun_torc.golden @@ -0,0 +1,13 @@ +@ 600 IN A 192.0.2.1 +www 600 IN A 192.0.2.2 +www 600 IN AAAA 2001:db8::1 +alias 600 IN CNAME www.example.com. +@ 3600 IN MX 10 mail.example.com. +@ 600 IN TXT "v=spf1 include:_spf.example.net -all" +sub 86400 IN NS ns1.example.net. +@ 600 IN CAA 0 issue "letsencrypt.org" +_sip._tcp 600 IN SRV 10 20 5060 sip.example.com. +_25._tcp.mail 600 IN TLSA 3 1 1 0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF +@ 600 IN SSHFP 1 2 0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF +@ 600 IN HTTPS 1 . alpn="h3,h2" ipv4hint="192.0.2.1" +_8443._foo 600 IN SVCB 16 svc.example.net. port="8443" alpn="h2" diff --git a/providers/porkbun/testdata/porkbun_toreq.golden b/providers/porkbun/testdata/porkbun_toreq.golden new file mode 100644 index 0000000000..c718963dde --- /dev/null +++ b/providers/porkbun/testdata/porkbun_toreq.golden @@ -0,0 +1,96 @@ +[ + { + "content": "192.0.2.1", + "name": "", + "ttl": "600", + "type": "A" + }, + { + "content": "192.0.2.2", + "name": "www", + "ttl": "600", + "type": "A" + }, + { + "content": "2001:db8::1", + "name": "www", + "ttl": "600", + "type": "AAAA" + }, + { + "content": "www.example.com.", + "name": "alias", + "ttl": "600", + "type": "CNAME" + }, + { + "content": "mail.example.com.", + "name": "", + "prio": "10", + "ttl": "3600", + "type": "MX" + }, + { + "content": "v=spf1 include:_spf.example.net -all", + "name": "", + "ttl": "600", + "type": "TXT" + }, + { + "content": "ns1.example.net.", + "name": "sub", + "ttl": "86400", + "type": "NS" + }, + { + "content": "0 issue \"letsencrypt.org\"", + "name": "", + "ttl": "600", + "type": "CAA" + }, + { + "content": "20 5060 sip.example.com.", + "name": "_sip._tcp", + "prio": "10", + "ttl": "600", + "type": "SRV" + }, + { + "content": "3 1 1 0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF", + "name": "_25._tcp.mail", + "ttl": "600", + "type": "TLSA" + }, + { + "content": "1 2 0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF", + "name": "", + "ttl": "600", + "type": "SSHFP" + }, + { + "content": "1 . alpn=h3,h2 ipv4hint=192.0.2.1", + "name": "", + "ttl": "600", + "type": "HTTPS" + }, + { + "content": "16 svc.example.net. port=8443 alpn=h2", + "name": "_8443._foo", + "ttl": "600", + "type": "SVCB" + }, + { + "includePath": "no", + "location": "https://example.net/landing", + "subdomain": "fwd", + "type": "temporary", + "wildcard": "no" + }, + { + "includePath": "yes", + "location": "https://example.net/moved", + "subdomain": "perm", + "type": "permanent", + "wildcard": "yes" + } +] diff --git a/providers/route53/convert_golden_test.go b/providers/route53/convert_golden_test.go new file mode 100644 index 0000000000..40fbf09431 --- /dev/null +++ b/providers/route53/convert_golden_test.go @@ -0,0 +1,19 @@ +package route53 + +import ( + "testing" + + r53Types "github.com/aws/aws-sdk-go-v2/service/route53/types" + + "github.com/DNSControl/dnscontrol/v5/models" + "github.com/DNSControl/dnscontrol/v5/pkg/providergolden" +) + +var testDomain = providergolden.Domain("ROUTE53") + +func TestNativeToRecordsGolden(t *testing.T) { + providergolden.CheckToRC(t, "route53_nativetorecords", testDomain, + func(dc *models.DomainConfig, native r53Types.ResourceRecordSet) ([]*models.RecordConfig, error) { + return nativeToRecords(dc, native, dc.Name) + }) +} diff --git a/providers/rwth/api.go b/providers/rwth/api.go index bdc919a6a4..9ec5fb24c1 100644 --- a/providers/rwth/api.go +++ b/providers/rwth/api.go @@ -15,7 +15,6 @@ import ( "time" "github.com/DNSControl/dnscontrol/v5/models" - "github.com/DNSControl/dnscontrol/v5/pkg/dnsrr" "github.com/DNSControl/dnscontrol/v5/pkg/printer" ) @@ -140,19 +139,13 @@ func (api *rwthProvider) getAllRecords(dc *models.DomainConfig) (models.Records, return nil, fmt.Errorf("failed fetching zone records for %q: %w", dc.Name, err) } for _, apiRecord := range response { - if checkIsLockedSystemAPIRecord(apiRecord) != nil { - continue - } - dnsRec, err := NewRR(apiRecord.Content) // Parse content as DNS record + recConfig, err := toRecordConfig(dc, apiRecord) if err != nil { return nil, err } - - recConfig, err := dnsrr.RRv2toRC(dc, dnsRec) // and make it a RC - if err != nil { - return nil, err + if recConfig == nil { + continue } - recConfig.Original = apiRecord // but keep our ApiRecord as the original records = append(records, recConfig) } diff --git a/providers/rwth/convert.go b/providers/rwth/convert.go index cf6cf958ab..761719a3b2 100644 --- a/providers/rwth/convert.go +++ b/providers/rwth/convert.go @@ -9,6 +9,7 @@ import ( dnsv2 "codeberg.org/miekg/dns" dnsutilv2 "codeberg.org/miekg/dns/dnsutil" "github.com/DNSControl/dnscontrol/v5/models" + "github.com/DNSControl/dnscontrol/v5/pkg/dnsrr" "github.com/DNSControl/dnscontrol/v5/pkg/prettyzone" ) @@ -45,6 +46,27 @@ func (api *rwthProvider) printRecConfig(rr models.RecordConfig) string { prefix, prettyzone.FormatLine([]int{10, 5, 2, 5, 0}, []string{rr.NameFQDN, ttl, "IN", typeStr, target}), comment) } +// toRecordConfig converts an RWTH record to a RecordConfig. It returns nil for +// the records that RWTH locks, which cannot be managed. +func toRecordConfig(dc *models.DomainConfig, apiRecord RecordReply) (*models.RecordConfig, error) { + if checkIsLockedSystemAPIRecord(apiRecord) != nil { + return nil, nil + } + + dnsRec, err := NewRR(apiRecord.Content) // Parse content as DNS record + if err != nil { + return nil, err + } + + recConfig, err := dnsrr.RRv2toRC(dc, dnsRec) // and make it a RC + if err != nil { + return nil, err + } + recConfig.Original = apiRecord // but keep our ApiRecord as the original + + return recConfig, nil +} + // NewRR returns custom dns.NewRR with RWTH default TTL. func NewRR(s string) (dnsv2.RR, error) { if len(s) > 0 && s[len(s)-1] != '\n' { // We need a closing newline diff --git a/providers/transip/convert_golden_test.go b/providers/transip/convert_golden_test.go new file mode 100644 index 0000000000..e4748b7c87 --- /dev/null +++ b/providers/transip/convert_golden_test.go @@ -0,0 +1,13 @@ +package transip + +import ( + "testing" + + "github.com/DNSControl/dnscontrol/v5/pkg/providergolden" +) + +var testDomain = providergolden.Domain("TRANSIP") + +func TestRecordToNativeGolden(t *testing.T) { + providergolden.CheckToNative(t, "transip_recordtonative", testDomain, recordToNative) +} diff --git a/providers/vercel/convert_golden_test.go b/providers/vercel/convert_golden_test.go new file mode 100644 index 0000000000..20bf6ddad0 --- /dev/null +++ b/providers/vercel/convert_golden_test.go @@ -0,0 +1,29 @@ +package vercel + +import ( + "testing" + + "github.com/DNSControl/dnscontrol/v5/models" + "github.com/DNSControl/dnscontrol/v5/pkg/providergolden" +) + +var testDomain = providergolden.Domain("VERCEL") + +func TestVercelRecordToRCGolden(t *testing.T) { + providergolden.CheckToRC(t, "vercel_vercelrecordtorc", testDomain, + func(dc *models.DomainConfig, native DNSRecord) ([]*models.RecordConfig, error) { + rc, err := vercelRecordToRC(dc, native) + return []*models.RecordConfig{rc}, err + }) +} + +func TestToVercelCreateRequestGolden(t *testing.T) { + providergolden.CheckToNative(t, "vercel_tovercelcreaterequest", testDomain, + func(rc *models.RecordConfig) (createDNSRecordRequest, error) { + return toVercelCreateRequest(testDomain, rc) + }) +} + +func TestToVercelUpdateRequestGolden(t *testing.T) { + providergolden.CheckToNative(t, "vercel_tovercelupdaterequest", testDomain, toVercelUpdateRequest) +} diff --git a/providers/websupport/convert_golden_test.go b/providers/websupport/convert_golden_test.go new file mode 100644 index 0000000000..ce143c366d --- /dev/null +++ b/providers/websupport/convert_golden_test.go @@ -0,0 +1,20 @@ +package websupport + +import ( + "testing" + + "github.com/DNSControl/dnscontrol/v5/models" + "github.com/DNSControl/dnscontrol/v5/pkg/providergolden" +) + +func TestToRecordConfigGolden(t *testing.T) { + providergolden.CheckToRC(t, "websupport_torecordconfig", testDomain, + func(dc *models.DomainConfig, native nativeRecord) ([]*models.RecordConfig, error) { + rc, err := toRecordConfig(dc, native) + return []*models.RecordConfig{rc}, err + }) +} + +func TestToNativeGolden(t *testing.T) { + providergolden.CheckToNative(t, "websupport_tonative", testDomain, toNative) +} diff --git a/providers/websupport/testdata/websupport.json b/providers/websupport/testdata/websupport.json new file mode 100644 index 0000000000..17f5d4054d --- /dev/null +++ b/providers/websupport/testdata/websupport.json @@ -0,0 +1,48 @@ +[ + { + "content": "1.2.3.4", + "id": 42, + "name": "example.com", + "ttl": 3600, + "type": "A" + }, + { + "content": "2a00:4b40:aaaa:2001::6", + "id": 43, + "name": "ipv6.example.com", + "ttl": 3600, + "type": "AAAA" + }, + { + "content": "ghs.example.net", + "id": 44, + "name": "www.example.com", + "ttl": 3600, + "type": "CNAME" + }, + { + "content": "mail.example.com", + "id": 45, + "name": "example.com", + "priority": 10, + "ttl": 3600, + "type": "MX" + }, + { + "content": "sip.example.com", + "id": 46, + "name": "_sip._tcp.example.com", + "port": 5060, + "priority": 10, + "ttl": 3600, + "type": "SRV", + "weight": 20 + }, + { + "content": "hello world", + "id": 47, + "name": "example.com", + "ttl": 3600, + "type": "TXT" + } +] diff --git a/providers/websupport/testdata/websupport.records b/providers/websupport/testdata/websupport.records new file mode 100644 index 0000000000..f24f721739 --- /dev/null +++ b/providers/websupport/testdata/websupport.records @@ -0,0 +1,6 @@ +@ 3600 IN A 1.2.3.4 +ipv6 3600 IN AAAA 2a00:4b40:aaaa:2001::6 +www 3600 IN CNAME ghs.example.net. +@ 3600 IN MX 10 mail.example.com. +_sip._tcp 3600 IN SRV 10 20 5060 sip.example.com. +@ 3600 IN TXT "hello world" diff --git a/providers/websupport/testdata/websupport_tonative.golden b/providers/websupport/testdata/websupport_tonative.golden new file mode 100644 index 0000000000..02a69b3128 --- /dev/null +++ b/providers/websupport/testdata/websupport_tonative.golden @@ -0,0 +1,42 @@ +[ + { + "type": "A", + "name": "@", + "content": "1.2.3.4", + "ttl": 3600 + }, + { + "type": "AAAA", + "name": "ipv6", + "content": "2a00:4b40:aaaa:2001::6", + "ttl": 3600 + }, + { + "type": "CNAME", + "name": "www", + "content": "ghs.example.net", + "ttl": 3600 + }, + { + "type": "MX", + "name": "@", + "content": "mail.example.com", + "ttl": 3600, + "priority": 10 + }, + { + "type": "SRV", + "name": "_sip._tcp", + "content": "sip.example.com", + "ttl": 3600, + "priority": 10, + "port": 5060, + "weight": 20 + }, + { + "type": "TXT", + "name": "@", + "content": "hello world", + "ttl": 3600 + } +] diff --git a/providers/websupport/testdata/websupport_torecordconfig.golden b/providers/websupport/testdata/websupport_torecordconfig.golden new file mode 100644 index 0000000000..f24f721739 --- /dev/null +++ b/providers/websupport/testdata/websupport_torecordconfig.golden @@ -0,0 +1,6 @@ +@ 3600 IN A 1.2.3.4 +ipv6 3600 IN AAAA 2a00:4b40:aaaa:2001::6 +www 3600 IN CNAME ghs.example.net. +@ 3600 IN MX 10 mail.example.com. +_sip._tcp 3600 IN SRV 10 20 5060 sip.example.com. +@ 3600 IN TXT "hello world"