diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..b6d9ff6 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,24 @@ +name: build + +on: + push: + branches: + - main + pull_request: + +jobs: + Test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + + - name: Set up Go + uses: actions/setup-go@fac708d6674e30b6ba41289acaab6d4b75aa0753 # v4.0.1 + with: + go-version-file: 'go.mod' + + - name: "Run build" + run: make build + + - name: "Run unit tests" + run: make test diff --git a/.golangci.yml b/.golangci.yml index aecd0b5..3396a2d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -2,7 +2,6 @@ run: timeout: 10m concurrency: 4 skip-dirs-use-default: false - tests: false linters: disable-all: true diff --git a/Makefile b/Makefile index a5c6681..7004466 100644 --- a/Makefile +++ b/Makefile @@ -32,4 +32,10 @@ fmt: lint: golangci-lint run -.PHONY: build clean fmt start enable lint +test: + go test -race -parallel=4 ./... + +test-acc: + ACC_TEST=yes go test -race -parallel=4 ./... + +.PHONY: build clean fmt start enable lint test test-acc diff --git a/go.mod b/go.mod index 350d47c..2228a07 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,12 @@ require ( github.com/hashicorp/vault/sdk v0.9.1 ) +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + require ( github.com/armon/go-metrics v0.4.1 // indirect github.com/armon/go-radix v1.0.0 // indirect @@ -46,6 +52,7 @@ require ( github.com/pierrec/lz4 v2.6.1+incompatible // indirect github.com/pkg/errors v0.9.1 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect + github.com/stretchr/testify v1.8.4 go.uber.org/atomic v1.9.0 // indirect golang.org/x/crypto v0.6.0 // indirect golang.org/x/net v0.8.0 // indirect diff --git a/go.sum b/go.sum index da43223..01e1701 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,7 @@ github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww= github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= @@ -144,6 +145,7 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= @@ -156,6 +158,7 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= @@ -183,7 +186,8 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= -github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= @@ -232,6 +236,7 @@ google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqw gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/pkg/client/client.go b/pkg/client/client.go index c8a2a2e..a2dabdf 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -9,7 +9,7 @@ import ( ) const ( - baseURL = "https://api.vercel.com/v3" + BaseURL = "https://api.vercel.com/v3" httpTimeout = 60 * time.Second ) @@ -20,6 +20,16 @@ type Client struct { } func New(apiKey string) *Client { + return &Client{ + baseURL: BaseURL, + httpClient: &http.Client{ + Timeout: httpTimeout, + }, + token: apiKey, + } +} + +func NewWithBaseURL(apiKey string, baseURL string) *Client { return &Client{ baseURL: baseURL, httpClient: &http.Client{ diff --git a/pkg/client/token.go b/pkg/client/token.go index ecd25f3..db97488 100644 --- a/pkg/client/token.go +++ b/pkg/client/token.go @@ -31,7 +31,7 @@ type DeleteAuthTokenRequest struct { } type DeleteAuthTokenResponse struct { - ID string `json:"id"` + ID string `json:"tokenId"` } func (c *Client) CreateAuthToken(ctx context.Context, req *CreateAuthTokenRequest) (*CreateAuthTokenResponse, error) { @@ -54,6 +54,14 @@ func (c *Client) CreateAuthToken(ctx context.Context, req *CreateAuthTokenReques return nil, err } + validStatusAbove := 200 + invalidStatusBelow := 300 + + ok := res.StatusCode >= validStatusAbove && res.StatusCode < invalidStatusBelow + if !ok { + return nil, fmt.Errorf("http error %d with response body '%+v'", res.StatusCode, string(body)) + } + if err = json.Unmarshal(body, &resp); err != nil { return resp, err } diff --git a/pkg/plugin/backend_test.go b/pkg/plugin/backend_test.go new file mode 100644 index 0000000..6bf7ba2 --- /dev/null +++ b/pkg/plugin/backend_test.go @@ -0,0 +1,106 @@ +package plugin + +import ( + "context" + "testing" + + "github.com/hashicorp/go-hclog" + "github.com/hashicorp/vault/sdk/logical" + "github.com/stretchr/testify/require" +) + +func TestFactory(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + cfg *logical.BackendConfig + wantErr bool + }{ + { + name: "Default", + cfg: &logical.BackendConfig{}, + wantErr: false, + }, + { + name: "MissingConfig", + cfg: nil, + wantErr: true, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ctx := context.Background() + b, err := Factory(ctx, tc.cfg) + if tc.wantErr { + require.Error(t, err) + require.Nil(t, b) + } else { + require.NoError(t, err) + } + }) + } +} + +func newTestBackend(t *testing.T) (*backend, logical.Storage) { + t.Helper() + + config := logical.TestBackendConfig() + config.StorageView = new(logical.InmemStorage) + config.Logger = hclog.NewNullLogger() + b, err := Factory(context.Background(), config) + require.NoError(t, err) + require.NotNil(t, b) + + return b.(*backend), config.StorageView +} + +func TestBackend_Config(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + input []byte + cfg *backendConfig + wantErr bool + }{ + { + name: "Default", + input: []byte(`{"api_key": "foo"}`), + cfg: &backendConfig{APIKey: "foo"}, + wantErr: false, + }, + { + name: "InvalidJSON", + input: []byte(`lorem ipsum`), + cfg: &backendConfig{}, + wantErr: true, + }, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + b, storage := newTestBackend(t) + + if tc.input != nil { + require.NoError(t, storage.Put(ctx, &logical.StorageEntry{ + Key: pathPatternConfig, + Value: tc.input, + })) + } + + _, err := b.getConfig(ctx, storage) + if tc.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/pkg/plugin/path_config.go b/pkg/plugin/path_config.go index 40f80fb..0402cd3 100644 --- a/pkg/plugin/path_config.go +++ b/pkg/plugin/path_config.go @@ -6,11 +6,13 @@ import ( "github.com/hashicorp/vault/sdk/framework" "github.com/hashicorp/vault/sdk/logical" + "github.com/thevilledev/vault-plugin-secrets-vercel/pkg/client" ) const ( pathPatternConfig = "config" pathConfigAPIKey = "api_key" + pathConfigBaseURL = "base_url" ) var ( @@ -19,7 +21,8 @@ var ( ) type backendConfig struct { - APIKey string `json:"api_key"` + APIKey string `json:"api_key"` + BaseURL string `json:"base_url"` } func (b *backend) pathConfig() []*framework.Path { @@ -31,6 +34,11 @@ func (b *backend) pathConfig() []*framework.Path { pathConfigAPIKey: { Type: framework.TypeString, Description: "API key for the Vercel account.", + Required: true, + }, + pathConfigBaseURL: { + Type: framework.TypeString, + Description: "Optional API base URL used by this backend.", }, }, @@ -54,8 +62,8 @@ func (b *backend) getConfig(ctx context.Context, storage logical.Storage) (*back return nil, err } - if e == nil { - return nil, nil + if e == nil || len(e.Value) == 0 { + return &backendConfig{}, nil } if err = e.DecodeJSON(&config); err != nil { @@ -77,10 +85,22 @@ func (b *backend) pathConfigWrite(ctx context.Context, req *logical.Request, } } + if v, ok := data.GetOk(pathConfigBaseURL); ok { + config.BaseURL, ok = v.(string) + if !ok { + b.Logger().Trace("type assertion failed: %+v", v) + return nil, errTypeAssertionFailed + } + } + if config.APIKey == "" { return nil, errMissingAPIKey } + if config.BaseURL == "" { + config.BaseURL = client.BaseURL + } + e, err := logical.StorageEntryJSON(pathPatternConfig, config) if err != nil { return nil, err diff --git a/pkg/plugin/path_config_test.go b/pkg/plugin/path_config_test.go new file mode 100644 index 0000000..e2bfda5 --- /dev/null +++ b/pkg/plugin/path_config_test.go @@ -0,0 +1,69 @@ +package plugin + +import ( + "context" + "testing" + + "github.com/hashicorp/vault/sdk/logical" + "github.com/stretchr/testify/require" +) + +func TestBackend_PathConfigRead(t *testing.T) { + t.Parallel() + + t.Run("ReadConfigurationValid", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + b, storage := newTestBackend(t) + + _, err := b.HandleRequest(ctx, &logical.Request{ + Storage: storage, + Operation: logical.ReadOperation, + Path: pathPatternConfig, + }) + require.Error(t, err) + }) +} + +func TestBackend_PathConfigWrite(t *testing.T) { + t.Parallel() + + t.Run("WriteConfigurationWithEmptyData", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + b, storage := newTestBackend(t) + + _, err := b.HandleRequest(ctx, &logical.Request{ + Storage: storage, + Operation: logical.CreateOperation, + Path: pathPatternConfig, + Data: map[string]any{}, + }) + require.Error(t, err) + require.Equal(t, err, errMissingAPIKey) + }) + + t.Run("WriteConfigurationWithValidData", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + b, storage := newTestBackend(t) + + _, err := b.HandleRequest(ctx, &logical.Request{ + Storage: storage, + Operation: logical.CreateOperation, + Path: pathPatternConfig, + Data: map[string]any{ + "api_key": "foo", + }, + }) + require.NoError(t, err) + + cfg, err := b.getConfig(ctx, storage) + require.NoError(t, err) + require.NotNil(t, cfg) + require.Equal(t, cfg.APIKey, "foo") + }) +} diff --git a/pkg/plugin/path_token.go b/pkg/plugin/path_token.go index 842050d..cd0b932 100644 --- a/pkg/plugin/path_token.go +++ b/pkg/plugin/path_token.go @@ -55,10 +55,10 @@ func (b *backend) pathTokenWrite(ctx context.Context, req *logical.Request, } if cfg.APIKey == "" { - return nil, fmt.Errorf("backend is missing api key") + return nil, errMissingAPIKey } - svc := service.New(cfg.APIKey) + svc := service.NewWithBaseURL(cfg.APIKey, cfg.BaseURL) ts := time.Now().UnixNano() name := fmt.Sprintf("%s-%d", keyPrefix, ts) diff --git a/pkg/plugin/path_token_test.go b/pkg/plugin/path_token_test.go new file mode 100644 index 0000000..08c5878 --- /dev/null +++ b/pkg/plugin/path_token_test.go @@ -0,0 +1,115 @@ +package plugin + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/hashicorp/vault/sdk/logical" + "github.com/stretchr/testify/require" + "github.com/thevilledev/vault-plugin-secrets-vercel/pkg/client" +) + +func TestToken_Create(t *testing.T) { + t.Parallel() + + t.Run("CreateTokenWithEmptyBackend", func(t *testing.T) { + t.Parallel() + + b, storage := newTestBackend(t) + + r, err := b.HandleRequest(context.Background(), &logical.Request{ + Storage: storage, + Operation: logical.CreateOperation, + Path: pathPatternToken, + Data: map[string]any{}, + }) + require.Equal(t, err, errMissingAPIKey) + require.Nil(t, r) + }) + + t.Run("CreateTokenWithValidBackend", func(t *testing.T) { + t.Parallel() + + b, storage := newTestBackend(t) + + ts := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + t.Helper() + + body, _ := json.Marshal(&client.CreateAuthTokenResponse{ + Token: client.Token{ + ID: "foo", + Name: "bar", + }, + BearerToken: "zyzz", + }) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) + }), + ) + defer ts.Close() + + _, err := b.HandleRequest(context.Background(), &logical.Request{ + Storage: storage, + Operation: logical.CreateOperation, + Path: pathPatternConfig, + Data: map[string]interface{}{ + "api_key": "foo", + "base_url": ts.URL, + }, + }) + require.NoError(t, err) + + r, err := b.HandleRequest(context.Background(), &logical.Request{ + Storage: storage, + Operation: logical.CreateOperation, + Path: pathPatternToken, + Data: map[string]any{}, + }) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, r.Data["token_id"], "foo") + require.Equal(t, r.Data["bearer_token"], "zyzz") + require.Equal(t, r.Secret.InternalData["token_id"], "foo") + }) + + t.Run("CreateTokenWithUpstreamAPIError", func(t *testing.T) { + t.Parallel() + + b, storage := newTestBackend(t) + + ts := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + t.Helper() + + body := []byte("not authorized") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write(body) + }), + ) + defer ts.Close() + + _, err := b.HandleRequest(context.Background(), &logical.Request{ + Storage: storage, + Operation: logical.CreateOperation, + Path: pathPatternConfig, + Data: map[string]interface{}{ + "api_key": "foo", + "base_url": ts.URL, + }, + }) + require.NoError(t, err) + + r, err := b.HandleRequest(context.Background(), &logical.Request{ + Storage: storage, + Operation: logical.CreateOperation, + Path: pathPatternToken, + Data: map[string]any{}, + }) + require.Error(t, err) + require.Nil(t, r) + }) +} diff --git a/pkg/plugin/revoke.go b/pkg/plugin/revoke.go index 6b227f2..686986c 100644 --- a/pkg/plugin/revoke.go +++ b/pkg/plugin/revoke.go @@ -32,7 +32,7 @@ func (b *backend) Revoke(ctx context.Context, req *logical.Request, _ *framework return nil, errTypeAssertionFailed } - err = svc.DeleteAuthToken(ctx, ks) + _, err = svc.DeleteAuthToken(ctx, ks) if err != nil { b.Logger().Error("token delete failed: %s", err) return nil, fmt.Errorf("failed to delete token") diff --git a/pkg/service/service.go b/pkg/service/service.go index 927babe..2f90263 100644 --- a/pkg/service/service.go +++ b/pkg/service/service.go @@ -21,6 +21,12 @@ func New(apiKey string) *Service { } } +func NewWithBaseURL(apiKey string, baseURL string) *Service { + return &Service{ + apiClient: client.NewWithBaseURL(apiKey, baseURL), + } +} + func (s *Service) CreateAuthToken(ctx context.Context, name string) (string, string, error) { r, err := s.apiClient.CreateAuthToken(ctx, &client.CreateAuthTokenRequest{ Name: name, @@ -32,10 +38,10 @@ func (s *Service) CreateAuthToken(ctx context.Context, name string) (string, str return r.Token.ID, r.BearerToken, nil } -func (s *Service) DeleteAuthToken(ctx context.Context, id string) error { - _, err := s.apiClient.DeleteAuthToken(ctx, &client.DeleteAuthTokenRequest{ +func (s *Service) DeleteAuthToken(ctx context.Context, id string) (string, error) { + r, err := s.apiClient.DeleteAuthToken(ctx, &client.DeleteAuthTokenRequest{ ID: id, }) - return err + return r.ID, err } diff --git a/pkg/service/service_test.go b/pkg/service/service_test.go index f860f72..7757619 100644 --- a/pkg/service/service_test.go +++ b/pkg/service/service_test.go @@ -4,20 +4,25 @@ import ( "context" "os" "testing" + + "github.com/stretchr/testify/require" ) -func TestToken(t *testing.T) { +func TestIntegration_Token(t *testing.T) { + if os.Getenv("ACC_TEST") == "" { + t.Skip("test skipped as ACC_TEST environment variable is not set") + } + token := os.Getenv("VERCEL_TOKEN") a := New(token) ctx := context.Background() + tokenID, bearerToken, err := a.CreateAuthToken(ctx, "foobar") - if err != nil { - t.Fatal(err) - } - if tokenID == "" { - t.Fatal("empty token") - } - if bearerToken == "" { - t.Fatal("empty bearer token") - } + require.NoError(t, err) + require.NotEmpty(t, tokenID) + require.NotEmpty(t, bearerToken) + + s, err := a.DeleteAuthToken(ctx, tokenID) + require.NoError(t, err) + require.Equal(t, s, tokenID) }