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(), "/") } 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.") }