diff options
| author | Ruben Beltran del Rio <jj@r.bdr.sh> | 2026-07-03 15:21:56 +0200 |
|---|---|---|
| committer | Ruben Beltran del Rio <jj@r.bdr.sh> | 2026-07-03 15:21:58 +0200 |
| commit | 95dc66cb108a311065917362a00b86c08767303d (patch) | |
| tree | 46ca65be6548224be0dd5a42bb86f2ecede14da8 /provider/dns_record_test.go | |
Diffstat (limited to 'provider/dns_record_test.go')
| -rw-r--r-- | provider/dns_record_test.go | 408 |
1 files changed, 408 insertions, 0 deletions
diff --git a/provider/dns_record_test.go b/provider/dns_record_test.go new file mode 100644 index 0000000..deaea67 --- /dev/null +++ b/provider/dns_record_test.go @@ -0,0 +1,408 @@ +package provider_test + +import ( + "context" + "net/http/httptest" + "testing" + + "github.com/blang/semver" + p "github.com/pulumi/pulumi-go-provider" + "github.com/pulumi/pulumi-go-provider/integration" + presource "github.com/pulumi/pulumi/sdk/v3/go/common/resource" + "github.com/pulumi/pulumi/sdk/v3/go/property" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "git.sr.ht/~rbdr/pulumi-porkbun/provider" +) + +const dnsRecordToken = "porkbun:index:DnsRecord" + +var testURN = presource.NewURN("test", "provider", "", dnsRecordToken, "test") + +func newTestServer(t *testing.T, fake *fakePorkbun) integration.Server { + t.Helper() + httpSrv := httptest.NewServer(fake) + t.Cleanup(httpSrv.Close) + + server := newIntegrationServer(t) + require.NoError(t, server.Configure(p.ConfigureRequest{ + Args: property.NewMap(map[string]property.Value{ + "apiKey": property.New(testAPIKey), + "secretApiKey": property.New(testSecretKey), + "baseUrl": property.New(httpSrv.URL), + }), + })) + return server +} + +func newIntegrationServer(t *testing.T) integration.Server { + t.Helper() + prov, err := provider.New() + require.NoError(t, err) + server, err := integration.NewServer(context.Background(), provider.Name, + semver.MustParse("1.0.0"), integration.WithProvider(prov)) + require.NoError(t, err) + return server +} + +func recordInputs(overrides map[string]property.Value) property.Map { + inputs := map[string]property.Value{ + "domain": property.New("example.com"), + "name": property.New("www"), + "type": property.New("A"), + "content": property.New("192.0.2.1"), + } + for k, v := range overrides { + inputs[k] = v + } + return property.NewMap(inputs) +} + +func checkedValues(overrides map[string]property.Value, extra map[string]property.Value) property.Map { + values := map[string]property.Value{ + "domain": property.New("example.com"), + "name": property.New("www"), + "type": property.New("A"), + "content": property.New("192.0.2.1"), + "ttl": property.New(600.0), + "prio": property.New(0.0), + } + for k, v := range extra { + values[k] = v + } + for k, v := range overrides { + values[k] = v + } + return property.NewMap(values) +} + +func checkedInputs(overrides map[string]property.Value) property.Map { + return checkedValues(overrides, nil) +} + +func checkedState(overrides map[string]property.Value) property.Map { + return checkedValues(overrides, map[string]property.Value{ + "fqdn": property.New("www.example.com"), + }) +} + +func TestDnsRecordLifecycle(t *testing.T) { + fake := newFakePorkbun(t) + server := newTestServer(t, fake) + + expectCreate := checkedState(nil) + expectContentUpdate := checkedState(map[string]property.Value{ + "content": property.New("192.0.2.2"), + }) + expectRename := checkedState(map[string]property.Value{ + "content": property.New("192.0.2.2"), + "name": property.New("api"), + "fqdn": property.New("api.example.com"), + }) + + integration.LifeCycleTest{ + Resource: dnsRecordToken, + Create: integration.Operation{ + Inputs: recordInputs(nil), + ExpectedOutput: &expectCreate, + Hook: func(inputs, output property.Map) { + assert.Equal(t, 1, fake.recordCount(), "exactly one record after create") + }, + }, + Updates: []integration.Operation{ + { + Inputs: recordInputs(map[string]property.Value{"content": property.New("192.0.2.2")}), + ExpectedOutput: &expectContentUpdate, + }, + { + Inputs: recordInputs(map[string]property.Value{ + "content": property.New("192.0.2.2"), + "name": property.New("api"), + }), + ExpectedOutput: &expectRename, + Hook: func(inputs, output property.Map) { + assert.Equal(t, 2, fake.recordCount(), "replacement creates the new record before deleting the old") + }, + }, + }, + }.Run(t, server) + + assert.Equal(t, 0, fake.recordCount(), "destroy must remove the record") + assert.Equal(t, 2, fake.createCalls, "one create plus one replacement") + assert.Equal(t, 1, fake.editCalls, "content change should edit in place") + assert.Equal(t, 2, fake.deleteCalls, "replacement delete plus final destroy") +} + +func TestCreateStoresPorkbunID(t *testing.T) { + fake := newFakePorkbun(t) + server := newTestServer(t, fake) + + resp, err := server.Create(p.CreateRequest{ + Urn: testURN, + Properties: checkedInputs(nil), + }) + require.NoError(t, err) + assert.NotEmpty(t, resp.ID) + require.NotNil(t, fake.record("example.com", resp.ID), "resource ID must be the Porkbun record ID") + + rec := fake.record("example.com", resp.ID) + assert.Equal(t, "www", rec.name) + assert.Equal(t, "A", rec.typ) + assert.Equal(t, "192.0.2.1", rec.content) + assert.Equal(t, 600, rec.ttl) +} + +func TestCreatePreviewMakesNoAPICalls(t *testing.T) { + fake := newFakePorkbun(t) + server := newTestServer(t, fake) + + resp, err := server.Create(p.CreateRequest{ + Urn: testURN, + Properties: checkedInputs(nil), + DryRun: true, + }) + require.NoError(t, err) + assert.Equal(t, 0, fake.createCalls, "preview must not create records") + assert.Equal(t, property.New("www"), resp.Properties.Get("name"), "inputs pass through the preview") + assert.Equal(t, property.New(property.Computed), resp.Properties.Get("fqdn"), + "computed outputs are unknown during preview") +} + +func TestUpdatePreviewMakesNoAPICalls(t *testing.T) { + fake := newFakePorkbun(t) + server := newTestServer(t, fake) + id := fake.seed("example.com", fakeRecord{name: "www", typ: "A", content: "192.0.2.1", ttl: 600}) + + _, err := server.Update(p.UpdateRequest{ + ID: id, + Urn: testURN, + State: checkedState(nil), + Inputs: checkedInputs(map[string]property.Value{"content": property.New("192.0.2.9")}), + DryRun: true, + }) + require.NoError(t, err) + assert.Equal(t, 0, fake.editCalls, "preview must not edit records") + assert.Equal(t, "192.0.2.1", fake.record("example.com", id).content) +} + +func TestDiff(t *testing.T) { + server := newTestServer(t, newFakePorkbun(t)) + + diff := func(newInputs property.Map) p.DiffResponse { + resp, err := server.Diff(p.DiffRequest{ + ID: "42", + Urn: testURN, + State: checkedState(nil), + Inputs: newInputs, + }) + require.NoError(t, err) + return resp + } + + t.Run("no changes", func(t *testing.T) { + resp := diff(checkedInputs(nil)) + assert.False(t, resp.HasChanges) + }) + + t.Run("content and ttl update in place", func(t *testing.T) { + resp := diff(checkedInputs(map[string]property.Value{ + "content": property.New("192.0.2.9"), + "ttl": property.New(900.0), + })) + assert.True(t, resp.HasChanges) + assert.Equal(t, p.Update, resp.DetailedDiff["content"].Kind) + assert.Equal(t, p.Update, resp.DetailedDiff["ttl"].Kind) + }) + + t.Run("domain change forces replacement", func(t *testing.T) { + resp := diff(checkedInputs(map[string]property.Value{ + "domain": property.New("example.org"), + })) + assert.True(t, resp.HasChanges) + assert.Equal(t, p.UpdateReplace, resp.DetailedDiff["domain"].Kind) + }) + + t.Run("name change forces replacement", func(t *testing.T) { + resp := diff(checkedInputs(map[string]property.Value{ + "name": property.New("api"), + })) + assert.True(t, resp.HasChanges) + assert.Equal(t, p.UpdateReplace, resp.DetailedDiff["name"].Kind) + }) + + t.Run("type change updates in place", func(t *testing.T) { + resp := diff(checkedInputs(map[string]property.Value{ + "type": property.New("CNAME"), + "content": property.New("target.example.net"), + })) + assert.True(t, resp.HasChanges) + assert.Equal(t, p.Update, resp.DetailedDiff["type"].Kind) + }) +} + +func TestCheck(t *testing.T) { + server := newTestServer(t, newFakePorkbun(t)) + + check := func(inputs property.Map) p.CheckResponse { + resp, err := server.Check(p.CheckRequest{Urn: testURN, Inputs: inputs}) + require.NoError(t, err) + return resp + } + + failedProperties := func(resp p.CheckResponse) []string { + props := []string{} + for _, f := range resp.Failures { + props = append(props, f.Property) + } + return props + } + + t.Run("applies defaults", func(t *testing.T) { + resp := check(recordInputs(nil)) + assert.Empty(t, resp.Failures) + assert.Equal(t, property.New(600.0), resp.Inputs.Get("ttl")) + assert.Equal(t, property.New(0.0), resp.Inputs.Get("prio")) + }) + + t.Run("normalizes case", func(t *testing.T) { + resp := check(recordInputs(map[string]property.Value{ + "type": property.New("a"), + "domain": property.New("EXAMPLE.com"), + "name": property.New("WWW"), + })) + assert.Empty(t, resp.Failures) + assert.Equal(t, property.New("A"), resp.Inputs.Get("type")) + assert.Equal(t, property.New("example.com"), resp.Inputs.Get("domain")) + assert.Equal(t, property.New("www"), resp.Inputs.Get("name")) + }) + + t.Run("rejects unknown record type", func(t *testing.T) { + resp := check(recordInputs(map[string]property.Value{"type": property.New("BOGUS")})) + assert.Equal(t, []string{"type"}, failedProperties(resp)) + }) + + t.Run("accepts ttl below the typical minimum", func(t *testing.T) { + resp := check(recordInputs(map[string]property.Value{"ttl": property.New(300.0)})) + assert.Empty(t, resp.Failures) + assert.Equal(t, property.New(300.0), resp.Inputs.Get("ttl")) + }) + + t.Run("rejects negative ttl", func(t *testing.T) { + resp := check(recordInputs(map[string]property.Value{"ttl": property.New(-1.0)})) + assert.Equal(t, []string{"ttl"}, failedProperties(resp)) + }) + + t.Run("rejects out of range prio", func(t *testing.T) { + resp := check(recordInputs(map[string]property.Value{"prio": property.New(70000.0)})) + assert.Equal(t, []string{"prio"}, failedProperties(resp)) + }) + + t.Run("rejects fully qualified name", func(t *testing.T) { + resp := check(recordInputs(map[string]property.Value{"name": property.New("www.example.com")})) + assert.Equal(t, []string{"name"}, failedProperties(resp)) + }) +} + +func TestReadRefreshesDrift(t *testing.T) { + fake := newFakePorkbun(t) + server := newTestServer(t, fake) + id := fake.seed("example.com", fakeRecord{name: "www", typ: "A", content: "192.0.2.99", ttl: 900}) + + resp, err := server.Read(p.ReadRequest{ + ID: id, + Urn: testURN, + Properties: checkedState(nil), + }) + require.NoError(t, err) + assert.Equal(t, id, resp.ID) + assert.Equal(t, property.New("192.0.2.99"), resp.Properties.Get("content")) + assert.Equal(t, property.New(900.0), resp.Properties.Get("ttl")) + assert.Equal(t, property.New("www"), resp.Properties.Get("name")) + assert.Equal(t, property.New("www.example.com"), resp.Properties.Get("fqdn")) +} + +func TestReadRootRecord(t *testing.T) { + fake := newFakePorkbun(t) + server := newTestServer(t, fake) + id := fake.seed("example.com", fakeRecord{name: "", typ: "A", content: "192.0.2.1", ttl: 600}) + + resp, err := server.Read(p.ReadRequest{ + ID: id, + Urn: testURN, + Properties: checkedState(map[string]property.Value{ + "name": property.New(""), + "fqdn": property.New("example.com"), + }), + }) + require.NoError(t, err) + assert.Equal(t, property.New(""), resp.Properties.Get("name")) + assert.Equal(t, property.New("example.com"), resp.Properties.Get("fqdn")) +} + +func TestReadDeletedRecord(t *testing.T) { + fake := newFakePorkbun(t) + server := newTestServer(t, fake) + + resp, err := server.Read(p.ReadRequest{ + ID: "9999", + Urn: testURN, + Properties: checkedState(nil), + }) + require.NoError(t, err) + assert.Empty(t, resp.ID, "an empty ID signals the record no longer exists") +} + +func TestImportWithCompositeID(t *testing.T) { + fake := newFakePorkbun(t) + server := newTestServer(t, fake) + id := fake.seed("example.com", fakeRecord{name: "mail", typ: "MX", content: "mx.example.com", ttl: 600, prio: 10}) + + resp, err := server.Read(p.ReadRequest{ + ID: "example.com/" + id, + Urn: testURN, + }) + require.NoError(t, err) + assert.Equal(t, id, resp.ID, "the canonical ID is the bare record ID") + assert.Equal(t, property.New("example.com"), resp.Properties.Get("domain")) + assert.Equal(t, property.New("mail"), resp.Properties.Get("name")) + assert.Equal(t, property.New("MX"), resp.Properties.Get("type")) + assert.Equal(t, property.New(10.0), resp.Properties.Get("prio")) + assert.Equal(t, property.New("mail.example.com"), resp.Properties.Get("fqdn")) +} + +func TestImportWithoutDomainFails(t *testing.T) { + server := newTestServer(t, newFakePorkbun(t)) + + _, err := server.Read(p.ReadRequest{ID: "12345", Urn: testURN}) + require.Error(t, err) + assert.Contains(t, err.Error(), "<domain>/<recordId>") +} + +func TestDeleteIsIdempotent(t *testing.T) { + fake := newFakePorkbun(t) + server := newTestServer(t, fake) + + err := server.Delete(p.DeleteRequest{ + ID: "4242", + Urn: testURN, + Properties: checkedState(nil), + }) + require.NoError(t, err) + assert.Equal(t, 1, fake.deleteCalls) + assert.Equal(t, 1, fake.retrieveCalls, "a failed delete should be verified with a lookup") +} + +func TestUpdateSurfacesAPIErrors(t *testing.T) { + fake := newFakePorkbun(t) + server := newTestServer(t, fake) + + _, err := server.Update(p.UpdateRequest{ + ID: "4242", + Urn: testURN, + State: checkedState(nil), + Inputs: checkedInputs(map[string]property.Value{"content": property.New("192.0.2.9")}), + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "Invalid record ID.") +} |