diff --git a/internal/cmd/storage/cmd.go b/internal/cmd/storage/cmd.go index 75f5e71ed..59832cc2c 100644 --- a/internal/cmd/storage/cmd.go +++ b/internal/cmd/storage/cmd.go @@ -51,6 +51,7 @@ func Command(preRun func(cmd *cobra.Command, args []string)) *cobra.Command { ListCommand(), UploadCommand(), DownloadCommand(), + DeleteCommand(), ) return cmd diff --git a/internal/cmd/storage/delete.go b/internal/cmd/storage/delete.go new file mode 100644 index 000000000..75cfded2b --- /dev/null +++ b/internal/cmd/storage/delete.go @@ -0,0 +1,50 @@ +package storage + +import ( + "errors" + "fmt" + + cmds "github.com/saucelabs/saucectl/internal/cmd" + "github.com/saucelabs/saucectl/internal/segment" + "github.com/saucelabs/saucectl/internal/usage" + "github.com/spf13/cobra" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +func DeleteCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a file from Sauce Storage.", + SilenceUsage: true, + Args: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 || args[0] == "" { + return errors.New("no ID specified") + } + + return nil + }, + PreRun: func(cmd *cobra.Command, args []string) { + tracker := segment.DefaultTracker + + go func() { + tracker.Collect( + cases.Title(language.English).String(cmds.FullName(cmd)), + usage.Properties{}.SetFlags(cmd.Flags()), + ) + _ = tracker.Close() + }() + }, + RunE: func(cmd *cobra.Command, args []string) error { + if err := appsClient.Delete(args[0]); err != nil { + return fmt.Errorf("failed to delete file: %v", err) + } + + println("File deleted successfully!") + + return nil + }, + } + + return cmd +} diff --git a/internal/http/appstore.go b/internal/http/appstore.go index 2aad647eb..5d7636e4b 100644 --- a/internal/http/appstore.go +++ b/internal/http/appstore.go @@ -223,6 +223,37 @@ func (s *AppStore) List(opts storage.ListOptions) (storage.List, error) { } } +func (s *AppStore) Delete(id string) error { + if id == "" { + return fmt.Errorf("no id specified") + } + + req, err := retryablehttp.NewRequest(http.MethodDelete, fmt.Sprintf("%s/v1/storage/files/%s", s.URL, id), nil) + if err != nil { + return err + } + + req.SetBasicAuth(s.Username, s.AccessKey) + + resp, err := s.HTTPClient.Do(req) + if err != nil { + return err + } + + switch resp.StatusCode { + case 200: + return nil + case 401, 403: + return storage.ErrAccessDenied + case 404: + return storage.ErrFileNotFound + case 429: + return storage.ErrTooManyRequest + default: + return s.newServerError(resp) + } +} + // newServerError inspects server error responses, trying to gather as much information as possible, especially if the body // conforms to the errorResponse format, and returns a storage.ServerError. func (s *AppStore) newServerError(resp *http.Response) *storage.ServerError { diff --git a/internal/http/appstore_test.go b/internal/http/appstore_test.go index 63b1e11eb..ddcec465e 100644 --- a/internal/http/appstore_test.go +++ b/internal/http/appstore_test.go @@ -7,12 +7,15 @@ import ( "net/http" "net/http/httptest" "os" + "path" "reflect" + "strings" "testing" "time" "github.com/hashicorp/go-retryablehttp" "github.com/saucelabs/saucectl/internal/storage" + "github.com/stretchr/testify/assert" "github.com/xtgo/uuid" "gotest.tools/v3/fs" @@ -318,3 +321,100 @@ func TestAppStore_List(t *testing.T) { }) } } + +func TestAppStore_Delete(t *testing.T) { + testUser := "test" + testPass := "test" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + if !strings.HasPrefix(r.URL.Path, "/v1/storage/files/") { + w.WriteHeader(http.StatusNotImplemented) + _, _ = w.Write([]byte("incorrect path")) + return + } + println(path.Base(r.URL.Path)) + if path.Base(r.URL.Path) == "" { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("missing file id")) + return + } + + user, pass, _ := r.BasicAuth() + if user != testUser || pass != testPass { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(http.StatusText(http.StatusForbidden))) + return + } + + w.WriteHeader(200) + // The real server's response body contains a JSON that describes the + // deleted item. We don't need that for this test. + })) + defer server.Close() + + type fields struct { + HTTPClient *retryablehttp.Client + URL string + Username string + AccessKey string + } + type args struct { + id string + } + tests := []struct { + name string + fields fields + args args + wantErr assert.ErrorAssertionFunc + }{ + { + name: "delete item successfully", + fields: fields{ + HTTPClient: NewRetryableClient(10 * time.Second), + URL: server.URL, + Username: testUser, + AccessKey: testPass, + }, + args: args{id: uuid.NewRandom().String()}, + wantErr: assert.NoError, + }, + { + name: "fail on wrong credentials", + fields: fields{ + HTTPClient: NewRetryableClient(10 * time.Second), + URL: server.URL, + Username: testUser + "1", + AccessKey: testPass + "1", + }, + args: args{id: uuid.NewRandom().String()}, + wantErr: assert.Error, + }, + { + name: "fail when no ID was specified", + fields: fields{ + HTTPClient: NewRetryableClient(10 * time.Second), + URL: server.URL, + Username: testUser, + AccessKey: testPass, + }, + args: args{id: ""}, + wantErr: assert.Error, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &AppStore{ + HTTPClient: tt.fields.HTTPClient, + URL: tt.fields.URL, + Username: tt.fields.Username, + AccessKey: tt.fields.AccessKey, + } + tt.wantErr(t, s.Delete(tt.args.id), fmt.Sprintf("Delete(%v)", tt.args.id)) + }) + } +} diff --git a/internal/saucecloud/retry/saucereportretrier_test.go b/internal/saucecloud/retry/saucereportretrier_test.go index 4edd76dd5..ed47c19fb 100644 --- a/internal/saucecloud/retry/saucereportretrier_test.go +++ b/internal/saucecloud/retry/saucereportretrier_test.go @@ -36,6 +36,10 @@ func (f *StubProjectUploader) List(opts storage.ListOptions) (storage.List, erro return storage.List{}, nil } +func (f *StubProjectUploader) Delete(id string) error { + return nil +} + type StubVDCJobReader struct { SauceReport saucereport.SauceReport } diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 5fccf9374..242d4733a 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -60,6 +60,7 @@ type AppService interface { // UploadStream uploads the contents of reader and stores them under the given filename. UploadStream(filename, description string, reader io.Reader) (Item, error) Download(id string) (io.ReadCloser, int64, error) + Delete(id string) error DownloadURL(url string) (io.ReadCloser, int64, error) List(opts ListOptions) (List, error) }