From 5da3df8ffebdb4b07ccb2000b4dac15f5c198fe8 Mon Sep 17 00:00:00 2001 From: Florian Schade Date: Fri, 10 Feb 2023 12:04:47 +0100 Subject: [PATCH] Space Trash-bin expiration cli (#5500) * add storage-users trash-bin cli add task to clean up outdated trash-bin resources add trash-bin cli purge-expired command to purge expired trash-bin resources add purge-expired task tests --- .../unreleased/space-trashbin-purge-cli.md | 23 ++ services/storage-users/pkg/command/root.go | 1 + services/storage-users/pkg/command/server.go | 23 ++ .../storage-users/pkg/command/trash_bin.go | 55 +++++ services/storage-users/pkg/config/config.go | 14 ++ .../pkg/config/defaults/defaultconfig.go | 11 + services/storage-users/pkg/event/event.go | 51 ++++ services/storage-users/pkg/event/service.go | 85 +++++++ services/storage-users/pkg/event/trigger.go | 21 ++ services/storage-users/pkg/task/task.go | 11 + .../storage-users/pkg/task/task_suite_test.go | 13 + services/storage-users/pkg/task/trash_bin.go | 112 +++++++++ .../storage-users/pkg/task/trash_bin_test.go | 230 ++++++++++++++++++ 13 files changed, 650 insertions(+) create mode 100644 changelog/unreleased/space-trashbin-purge-cli.md create mode 100644 services/storage-users/pkg/command/trash_bin.go create mode 100644 services/storage-users/pkg/event/event.go create mode 100644 services/storage-users/pkg/event/service.go create mode 100644 services/storage-users/pkg/event/trigger.go create mode 100644 services/storage-users/pkg/task/task.go create mode 100644 services/storage-users/pkg/task/task_suite_test.go create mode 100644 services/storage-users/pkg/task/trash_bin.go create mode 100644 services/storage-users/pkg/task/trash_bin_test.go diff --git a/changelog/unreleased/space-trashbin-purge-cli.md b/changelog/unreleased/space-trashbin-purge-cli.md new file mode 100644 index 000000000..86d30bc46 --- /dev/null +++ b/changelog/unreleased/space-trashbin-purge-cli.md @@ -0,0 +1,23 @@ +Enhancement: Cli to purge expired trash-bin items + +Introduction of a new cli command to purge old trash-bin items. +The command is part of the `storage-users` service and can be used as follows: + +`ocis storage-users trash-bin purge-expired`. + +The `purge-expired` command configuration is done in the `ocis`configuration or as usual by using environment variables. + +ENV `STORAGE_USERS_PURGE_TRASH_BIN_USER_ID` is used to obtain space trash-bin information and takes the system admin user as the default `OCIS_ADMIN_USER_ID`. +It should be noted, that this is only set by default in the single binary. The command only considers spaces to which the user has access and delete permission. + +ENV `STORAGE_USERS_PURGE_TRASH_BIN_PERSONAL_DELETE_BEFORE` has a default value of `30 days`, which means the command will delete all files older than `30 days`. +The value is human-readable, valid values are `24h`, `60m`, `60s` etc. `0` is equivalent to disable and prevents the deletion of `personal space` trash-bin files. + +ENV `STORAGE_USERS_PURGE_TRASH_BIN_PROJECT_DELETE_BEFORE` has a default value of `30 days`, which means the command will delete all files older than `30 days`. +The value is human-readable, valid values are `24h`, `60m`, `60s` etc. `0` is equivalent to disable and prevents the deletion of `project space` trash-bin files. + +Likewise, only spaces of the type `project` and `personal` are taken into account. +Spaces of type `virtual`, for example, are ignored. + +https://github.com/owncloud/ocis/pull/5500 +https://github.com/owncloud/ocis/issues/5499 diff --git a/services/storage-users/pkg/command/root.go b/services/storage-users/pkg/command/root.go index 21c3089b6..848e866c7 100644 --- a/services/storage-users/pkg/command/root.go +++ b/services/storage-users/pkg/command/root.go @@ -19,6 +19,7 @@ func GetCommands(cfg *config.Config) cli.Commands { // interaction with this service Uploads(cfg), + TrashBin(cfg), // infos about this service Health(cfg), diff --git a/services/storage-users/pkg/command/server.go b/services/storage-users/pkg/command/server.go index 8c976eb37..ffff3afe2 100644 --- a/services/storage-users/pkg/command/server.go +++ b/services/storage-users/pkg/command/server.go @@ -7,6 +7,7 @@ import ( "path" "github.com/cs3org/reva/v2/cmd/revad/runtime" + "github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool" "github.com/gofrs/uuid" "github.com/oklog/run" "github.com/owncloud/ocis/v2/ocis-pkg/config/configlog" @@ -15,6 +16,7 @@ import ( "github.com/owncloud/ocis/v2/ocis-pkg/version" "github.com/owncloud/ocis/v2/services/storage-users/pkg/config" "github.com/owncloud/ocis/v2/services/storage-users/pkg/config/parser" + "github.com/owncloud/ocis/v2/services/storage-users/pkg/event" "github.com/owncloud/ocis/v2/services/storage-users/pkg/logging" "github.com/owncloud/ocis/v2/services/storage-users/pkg/revaconfig" "github.com/owncloud/ocis/v2/services/storage-users/pkg/server/debug" @@ -88,6 +90,27 @@ func Server(cfg *config.Config) *cli.Command { logger.Fatal().Err(err).Msg("failed to register the grpc endpoint") } + { + stream, err := event.NewStream(cfg.Events) + if err != nil { + logger.Fatal().Err(err).Msg("can't connect to nats") + } + + gw, err := pool.GetGatewayServiceClient(cfg.Reva.Address) + if err != nil { + return err + } + + eventSVC, err := event.NewService(gw, stream, logger, *cfg) + if err != nil { + logger.Fatal().Err(err).Msg("can't create event service") + } + + gr.Add(eventSVC.Run, func(_ error) { + cancel() + }) + } + return gr.Run() }, } diff --git a/services/storage-users/pkg/command/trash_bin.go b/services/storage-users/pkg/command/trash_bin.go new file mode 100644 index 000000000..fdff7e1d5 --- /dev/null +++ b/services/storage-users/pkg/command/trash_bin.go @@ -0,0 +1,55 @@ +package command + +import ( + "time" + + "github.com/cs3org/reva/v2/pkg/events" + "github.com/owncloud/ocis/v2/ocis-pkg/config/configlog" + "github.com/owncloud/ocis/v2/services/storage-users/pkg/config" + "github.com/owncloud/ocis/v2/services/storage-users/pkg/config/parser" + "github.com/owncloud/ocis/v2/services/storage-users/pkg/event" + "github.com/urfave/cli/v2" +) + +// TrashBin wraps trash-bin related sub-commands. +func TrashBin(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "trash-bin", + Usage: "manage trash-bin's", + Subcommands: []*cli.Command{ + PurgeExpiredResources(cfg), + }, + } +} + +// PurgeExpiredResources cli command removes old trash-bin items. +func PurgeExpiredResources(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "purge-expired", + Usage: "Purge expired trash-bin items", + Flags: []cli.Flag{}, + Before: func(c *cli.Context) error { + return configlog.ReturnFatal(parser.ParseConfig(cfg)) + }, + Action: func(c *cli.Context) error { + stream, err := event.NewStream(cfg.Events) + if err != nil { + return err + } + + if err := events.Publish(stream, event.PurgeTrashBin{ExecutionTime: time.Now()}); err != nil { + return err + } + + // go-micro nats implementation uses async publishing, + // therefore we need to manually wait. + // + // FIXME: upstream pr + // + // https://github.com/go-micro/plugins/blob/3e77393890683be4bacfb613bc5751867d584692/v4/events/natsjs/nats.go#L115 + time.Sleep(5 * time.Second) + + return nil + }, + } +} diff --git a/services/storage-users/pkg/config/config.go b/services/storage-users/pkg/config/config.go index 7c636af72..30e60057a 100644 --- a/services/storage-users/pkg/config/config.go +++ b/services/storage-users/pkg/config/config.go @@ -2,6 +2,7 @@ package config import ( "context" + "time" "github.com/owncloud/ocis/v2/ocis-pkg/shared" ) @@ -32,6 +33,7 @@ type Config struct { ExposeDataServer bool `yaml:"expose_data_server" env:"STORAGE_USERS_EXPOSE_DATA_SERVER" desc:"Exposes the data server directly to users and bypasses the data gateway. Ensure that the data server address is reachable by users."` ReadOnly bool `yaml:"readonly" env:"STORAGE_USERS_READ_ONLY" desc:"Set this storage to be read-only."` UploadExpiration int64 `yaml:"upload_expiration" env:"STORAGE_USERS_UPLOAD_EXPIRATION" desc:"Duration in seconds after which uploads will expire."` + Tasks Tasks `yaml:"tasks"` Supervised bool `yaml:"-"` Context context.Context `yaml:"-"` @@ -218,3 +220,15 @@ type LocalDriver struct { ShareFolder string `yaml:"share_folder"` UserLayout string `yaml:"user_layout"` } + +// Tasks wraps task configurations +type Tasks struct { + PurgeTrashBin PurgeTrashBin `yaml:"purge_trash_bin"` +} + +// PurgeTrashBin contains all necessary configurations to clean up the respective trash cans +type PurgeTrashBin struct { + UserID string `yaml:"user_id" env:"OCIS_ADMIN_USER_ID;STORAGE_USERS_PURGE_TRASH_BIN_USER_ID" desc:"ID of the user who collects all necessary information for deletion."` + PersonalDeleteBefore time.Duration `yaml:"personal_delete_before" env:"STORAGE_USERS_PURGE_TRASH_BIN_PERSONAL_DELETE_BEFORE" desc:"Specifies the period of time in which items that have been in the personal trash-bin for longer than this value should be deleted. A value of 0 means no automatic deletion"` + ProjectDeleteBefore time.Duration `yaml:"project_delete_before" env:"STORAGE_USERS_PURGE_TRASH_BIN_PROJECT_DELETE_BEFORE" desc:"Specifies the period of time in which items that have been in the project trash-bin for longer than this value should be deleted. A value of 0 means no automatic deletion"` +} diff --git a/services/storage-users/pkg/config/defaults/defaultconfig.go b/services/storage-users/pkg/config/defaults/defaultconfig.go index 83e2d6470..6a413d1da 100644 --- a/services/storage-users/pkg/config/defaults/defaultconfig.go +++ b/services/storage-users/pkg/config/defaults/defaultconfig.go @@ -2,6 +2,7 @@ package defaults import ( "path/filepath" + "time" "github.com/owncloud/ocis/v2/ocis-pkg/config/defaults" "github.com/owncloud/ocis/v2/ocis-pkg/shared" @@ -87,6 +88,12 @@ func DefaultConfig() *config.Config { Store: "memory", Database: "users", }, + Tasks: config.Tasks{ + PurgeTrashBin: config.PurgeTrashBin{ + ProjectDeleteBefore: 30 * 24 * time.Hour, + PersonalDeleteBefore: 30 * 24 * time.Hour, + }, + }, } } @@ -139,6 +146,10 @@ func EnsureDefaults(cfg *config.Config) { cfg.GRPC.TLS.Key = cfg.Commons.GRPCServiceTLS.Key } } + + if cfg.Tasks.PurgeTrashBin.UserID == "" && cfg.Commons != nil { + cfg.Tasks.PurgeTrashBin.UserID = cfg.Commons.AdminUserID + } } func Sanitize(cfg *config.Config) { diff --git a/services/storage-users/pkg/event/event.go b/services/storage-users/pkg/event/event.go new file mode 100644 index 000000000..bd5273f7d --- /dev/null +++ b/services/storage-users/pkg/event/event.go @@ -0,0 +1,51 @@ +package event + +import ( + "crypto/tls" + "crypto/x509" + "os" + + "github.com/cs3org/reva/v2/pkg/events/stream" + "github.com/go-micro/plugins/v4/events/natsjs" + ociscrypto "github.com/owncloud/ocis/v2/ocis-pkg/crypto" + "github.com/owncloud/ocis/v2/services/storage-users/pkg/config" + "go-micro.dev/v4/events" +) + +// NewStream prepares the requested nats stream and returns it. +func NewStream(cfg config.Events) (events.Stream, error) { + var tlsConf *tls.Config + + if cfg.EnableTLS { + var rootCAPool *x509.CertPool + if cfg.TLSRootCaCertPath != "" { + rootCrtFile, err := os.Open(cfg.TLSRootCaCertPath) + if err != nil { + return nil, err + } + + rootCAPool, err = ociscrypto.NewCertPoolFromPEM(rootCrtFile) + if err != nil { + return nil, err + } + cfg.TLSInsecure = false + } + + tlsConf = &tls.Config{ + MinVersion: tls.VersionTLS12, + RootCAs: rootCAPool, + } + } + + s, err := stream.Nats( + natsjs.TLSConfig(tlsConf), + natsjs.Address(cfg.Addr), + natsjs.ClusterID(cfg.ClusterID), + ) + + if err != nil { + return nil, err + } + + return s, nil +} diff --git a/services/storage-users/pkg/event/service.go b/services/storage-users/pkg/event/service.go new file mode 100644 index 000000000..0f78ba770 --- /dev/null +++ b/services/storage-users/pkg/event/service.go @@ -0,0 +1,85 @@ +package event + +import ( + "time" + + apiGateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + apiUser "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + "github.com/cs3org/reva/v2/pkg/events" + "github.com/owncloud/ocis/v2/ocis-pkg/log" + "github.com/owncloud/ocis/v2/services/storage-users/pkg/config" + "github.com/owncloud/ocis/v2/services/storage-users/pkg/task" +) + +const ( + consumerGroup = "storage-users" +) + +// Service wraps all common logic that is needed to react to incoming events. +type Service struct { + gatewayClient apiGateway.GatewayAPIClient + eventStream events.Stream + logger log.Logger + config config.Config +} + +// NewService prepares and returns a Service implementation. +func NewService(gatewayClient apiGateway.GatewayAPIClient, eventStream events.Stream, logger log.Logger, conf config.Config) (Service, error) { + svc := Service{ + gatewayClient: gatewayClient, + eventStream: eventStream, + logger: logger, + config: conf, + } + + return svc, nil +} + +// Run to fulfil Runner interface +func (s Service) Run() error { + ch, err := events.Consume(s.eventStream, consumerGroup, PurgeTrashBin{}) + if err != nil { + return err + } + + for e := range ch { + var errs []error + + switch ev := e.(type) { + case PurgeTrashBin: + executionTime := ev.ExecutionTime + if executionTime.IsZero() { + executionTime = time.Now() + } + + executantID := ev.ExecutantID + if executantID == nil { + executantID = &apiUser.UserId{OpaqueId: s.config.Tasks.PurgeTrashBin.UserID} + } + + tasks := map[task.SpaceType]time.Time{ + task.Project: executionTime.Add(-s.config.Tasks.PurgeTrashBin.ProjectDeleteBefore), + task.Personal: executionTime.Add(-s.config.Tasks.PurgeTrashBin.PersonalDeleteBefore), + } + + for spaceType, deleteBefore := range tasks { + // skip task execution if the deleteBefore time is the same as the now time, + // which indicates that the duration configuration for this space type is set to 0 which is the equivalent to disabled. + if deleteBefore.Equal(executionTime) { + continue + } + + if err = task.PurgeTrashBin(executantID, deleteBefore, spaceType, s.gatewayClient, s.config.Commons.MachineAuthAPIKey); err != nil { + errs = append(errs, err) + } + } + + } + + for _, err := range errs { + s.logger.Error().Err(err).Interface("event", e) + } + } + + return nil +} diff --git a/services/storage-users/pkg/event/trigger.go b/services/storage-users/pkg/event/trigger.go new file mode 100644 index 000000000..a190f4da4 --- /dev/null +++ b/services/storage-users/pkg/event/trigger.go @@ -0,0 +1,21 @@ +package event + +import ( + "encoding/json" + "time" + + apiUser "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" +) + +// PurgeTrashBin wraps all needed information to purge a trash-bin +type PurgeTrashBin struct { + ExecutantID *apiUser.UserId + ExecutionTime time.Time +} + +// Unmarshal to fulfill umarshaller interface +func (PurgeTrashBin) Unmarshal(v []byte) (interface{}, error) { + e := PurgeTrashBin{} + err := json.Unmarshal(v, &e) + return e, err +} diff --git a/services/storage-users/pkg/task/task.go b/services/storage-users/pkg/task/task.go new file mode 100644 index 000000000..c1bfe9bb6 --- /dev/null +++ b/services/storage-users/pkg/task/task.go @@ -0,0 +1,11 @@ +package task + +// SpaceType represents known space types +type SpaceType string + +const ( + // Personal represents a space of type personal + Personal SpaceType = "personal" + // Project represents a space of type project + Project SpaceType = "project" +) diff --git a/services/storage-users/pkg/task/task_suite_test.go b/services/storage-users/pkg/task/task_suite_test.go new file mode 100644 index 000000000..dbcb7996a --- /dev/null +++ b/services/storage-users/pkg/task/task_suite_test.go @@ -0,0 +1,13 @@ +package task_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestTask(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Task Suite") +} diff --git a/services/storage-users/pkg/task/trash_bin.go b/services/storage-users/pkg/task/trash_bin.go new file mode 100644 index 000000000..354f9cf09 --- /dev/null +++ b/services/storage-users/pkg/task/trash_bin.go @@ -0,0 +1,112 @@ +package task + +import ( + "fmt" + "time" + + apiGateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + apiUser "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + apiRpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + apiProvider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + "github.com/cs3org/reva/v2/pkg/errtypes" + "github.com/cs3org/reva/v2/pkg/utils" +) + +// PurgeTrashBin can be used to purge space trash-bin's, +// the provided executantID must have space access. +// removeBefore specifies how long an item must be in the trash-bin to be deleted, +// items that stay there for a shorter time are ignored and kept in place. +func PurgeTrashBin(executantID *apiUser.UserId, deleteBefore time.Time, spaceType SpaceType, gwc apiGateway.GatewayAPIClient, machineAuthAPIKey string) error { + executantCtx, _, err := utils.Impersonate(executantID, gwc, machineAuthAPIKey) + if err != nil { + return err + } + + listStorageSpacesResponse, err := gwc.ListStorageSpaces(executantCtx, &apiProvider.ListStorageSpacesRequest{ + Filters: []*apiProvider.ListStorageSpacesRequest_Filter{ + { + Type: apiProvider.ListStorageSpacesRequest_Filter_TYPE_SPACE_TYPE, + Term: &apiProvider.ListStorageSpacesRequest_Filter_SpaceType{ + SpaceType: string(spaceType), + }, + }, + }, + }) + if err != nil { + return err + } + + for _, storageSpace := range listStorageSpacesResponse.StorageSpaces { + var ( + err error + impersonationID *apiUser.UserId + storageSpaceReference = &apiProvider.Reference{ + ResourceId: storageSpace.GetRoot(), + } + ) + + switch SpaceType(storageSpace.GetSpaceType()) { + case Personal: + impersonationID = storageSpace.GetOwner().GetId() + case Project: + var permissionsMap map[string]*apiProvider.ResourcePermissions + err := utils.ReadJSONFromOpaque(storageSpace.GetOpaque(), "grants", &permissionsMap) + if err != nil { + break + } + + for id, permissions := range permissionsMap { + if !permissions.Delete { + continue + } + + impersonationID = &apiUser.UserId{ + OpaqueId: id, + } + break + } + default: + continue + } + + if err != nil { + return err + } + + if impersonationID == nil { + return fmt.Errorf("can't impersonate space user for space: %s", storageSpace.GetId().GetOpaqueId()) + } + + impersonatedCtx, _, err := utils.Impersonate(impersonationID, gwc, machineAuthAPIKey) + if err != nil { + return err + } + + listRecycleResponse, err := gwc.ListRecycle(impersonatedCtx, &apiProvider.ListRecycleRequest{Ref: storageSpaceReference}) + if err != nil { + return err + } + + for _, recycleItem := range listRecycleResponse.GetRecycleItems() { + doDelete := utils.TSToUnixNano(recycleItem.DeletionTime) < utils.TSToUnixNano(utils.TimeToTS(deleteBefore)) + if !doDelete { + continue + } + + purgeRecycleResponse, err := gwc.PurgeRecycle(impersonatedCtx, &apiProvider.PurgeRecycleRequest{ + Ref: storageSpaceReference, + Key: recycleItem.Key, + }) + + if purgeRecycleResponse.GetStatus().GetCode() != apiRpc.Code_CODE_OK { + return errtypes.NewErrtypeFromStatus(purgeRecycleResponse.Status) + } + + if err != nil { + return err + } + } + } + + return nil +} diff --git a/services/storage-users/pkg/task/trash_bin_test.go b/services/storage-users/pkg/task/trash_bin_test.go new file mode 100644 index 000000000..1da8c231d --- /dev/null +++ b/services/storage-users/pkg/task/trash_bin_test.go @@ -0,0 +1,230 @@ +package task_test + +import ( + "context" + "encoding/json" + "errors" + "google.golang.org/grpc" + "time" + + apiGateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + apiUser "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + apiRpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + apiProvider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + apiTypes "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" + "github.com/cs3org/reva/v2/pkg/rgrpc/status" + "github.com/cs3org/reva/v2/pkg/utils" + cs3mocks "github.com/cs3org/reva/v2/tests/cs3mocks/mocks" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/owncloud/ocis/v2/services/storage-users/pkg/task" + "github.com/stretchr/testify/mock" +) + +func MustMarshal(v any) []byte { + b, err := json.Marshal(v) + if err != nil { + panic(err) + } + return b +} + +var _ = Describe("trash", func() { + var ( + gwc *cs3mocks.GatewayAPIClient + ctx context.Context + now time.Time + genericError error + user *apiUser.User + getUserResponse *apiUser.GetUserResponse + authenticateResponse *apiGateway.AuthenticateResponse + listStorageSpacesResponse *apiProvider.ListStorageSpacesResponse + personalSpace *apiProvider.StorageSpace + projectSpace *apiProvider.StorageSpace + virtualSpace *apiProvider.StorageSpace + ) + + BeforeEach(func() { + gwc = &cs3mocks.GatewayAPIClient{} + ctx = context.Background() + now = time.Now() + genericError = errors.New("any") + getUserResponse = &apiUser.GetUserResponse{ + Status: status.NewOK(ctx), + } + authenticateResponse = &apiGateway.AuthenticateResponse{ + Status: status.NewOK(ctx), + Token: "", + } + listStorageSpacesResponse = &apiProvider.ListStorageSpacesResponse{ + Status: status.NewOK(ctx), + StorageSpaces: []*apiProvider.StorageSpace{}, + } + personalSpace = &apiProvider.StorageSpace{ + SpaceType: "personal", + Id: &apiProvider.StorageSpaceId{ + OpaqueId: "personal", + }, + Root: &apiProvider.ResourceId{ + OpaqueId: "personal", + }, + } + projectSpace = &apiProvider.StorageSpace{ + SpaceType: "project", + Id: &apiProvider.StorageSpaceId{ + OpaqueId: "project", + }, + Root: &apiProvider.ResourceId{ + OpaqueId: "project", + }, + Opaque: &apiTypes.Opaque{}, + } + // virtual is here as an example, + // the task ignores all space types expect `project` and `personal`. + virtualSpace = &apiProvider.StorageSpace{ + SpaceType: "virtual", + Id: &apiProvider.StorageSpaceId{ + OpaqueId: "virtual", + }, + Root: &apiProvider.ResourceId{ + OpaqueId: "virtual", + }, + } + user = &apiUser.User{ + Id: &apiUser.UserId{ + OpaqueId: "user", + }, + } + + }) + + Describe("PurgeTrashBin", func() { + It("throws an error if the user cannot authenticate", func() { + gwc.On("GetUser", mock.Anything, mock.Anything).Return(getUserResponse, nil) + gwc.On("Authenticate", mock.Anything, mock.Anything).Return(nil, genericError) + + err := task.PurgeTrashBin(user.Id, now, task.Project, gwc, "") + Expect(err).To(HaveOccurred()) + }) + It("throws an error if space listing fails", func() { + gwc.On("GetUser", mock.Anything, mock.Anything).Return(getUserResponse, nil) + gwc.On("Authenticate", mock.Anything, mock.Anything).Return(authenticateResponse, nil) + gwc.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(nil, genericError) + + err := task.PurgeTrashBin(user.Id, now, task.Project, gwc, "") + Expect(err).To(HaveOccurred()) + }) + It("throws an error if a personal space user can't be impersonated", func() { + listStorageSpacesResponse.StorageSpaces = []*apiProvider.StorageSpace{personalSpace} + gwc.On("GetUser", mock.Anything, mock.Anything).Return(getUserResponse, nil) + gwc.On("Authenticate", mock.Anything, mock.Anything).Return(authenticateResponse, nil) + gwc.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(listStorageSpacesResponse, nil) + + err := task.PurgeTrashBin(user.Id, now, task.Project, gwc, "") + Expect(err).To(MatchError(errors.New("can't impersonate space user for space: personal"))) + }) + It("throws an error if a project space user can't be impersonated", func() { + listStorageSpacesResponse.StorageSpaces = []*apiProvider.StorageSpace{projectSpace} + gwc.On("GetUser", mock.Anything, mock.Anything).Return(getUserResponse, nil) + gwc.On("Authenticate", mock.Anything, mock.Anything).Return(authenticateResponse, nil) + gwc.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(listStorageSpacesResponse, nil) + + err := task.PurgeTrashBin(user.Id, now, task.Project, gwc, "") + Expect(err).To(MatchError(errors.New("can't impersonate space user for space: project"))) + }) + It("throws an error if a project space has no user with delete permissions", func() { + listStorageSpacesResponse.StorageSpaces = []*apiProvider.StorageSpace{projectSpace} + projectSpace.Opaque.Map = map[string]*apiTypes.OpaqueEntry{ + "grants": { + Value: MustMarshal(map[string]*apiProvider.ResourcePermissions{ + "admin": { + Delete: false, + }, + }), + }, + } + gwc.On("GetUser", mock.Anything, mock.Anything).Return(getUserResponse, nil) + gwc.On("Authenticate", mock.Anything, mock.Anything).Return(authenticateResponse, nil) + gwc.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(listStorageSpacesResponse, nil) + + err := task.PurgeTrashBin(user.Id, now, task.Project, gwc, "") + Expect(err).To(MatchError(errors.New("can't impersonate space user for space: project"))) + }) + It("only deletes items older than the specified period", func() { + var ( + recycleItems = map[string][]*apiProvider.RecycleItem{ + "personal": { + {Key: "now", DeletionTime: utils.TimeToTS(now)}, + {Key: "after", DeletionTime: utils.TimeToTS(now.Add(1 * time.Second))}, + {Key: "before", DeletionTime: utils.TimeToTS(now.Add(-1 * time.Second))}, + }, + "project": { + {Key: "now", DeletionTime: utils.TimeToTS(now)}, + {Key: "after", DeletionTime: utils.TimeToTS(now.Add(1 * time.Minute))}, + {Key: "before", DeletionTime: utils.TimeToTS(now.Add(-1 * time.Minute))}, + }, + "virtual": { + {Key: "now", DeletionTime: utils.TimeToTS(now)}, + {Key: "after", DeletionTime: utils.TimeToTS(now.Add(1 * time.Hour))}, + {Key: "before", DeletionTime: utils.TimeToTS(now.Add(-1 * time.Hour))}, + }, + } + ) + + personalSpace.Owner = user + listStorageSpacesResponse.StorageSpaces = []*apiProvider.StorageSpace{ + personalSpace, + projectSpace, + virtualSpace, + } + projectSpace.Opaque.Map = map[string]*apiTypes.OpaqueEntry{ + "grants": { + Decoder: "json", + Value: MustMarshal(map[string]*apiProvider.ResourcePermissions{ + "admin": { + Delete: true, + }, + }), + }, + } + + gwc.On("GetUser", mock.Anything, mock.Anything).Return(getUserResponse, nil) + gwc.On("Authenticate", mock.Anything, mock.Anything).Return(authenticateResponse, nil) + gwc.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(listStorageSpacesResponse, nil) + gwc.On("ListRecycle", mock.Anything, mock.Anything).Return( + func(_ context.Context, req *apiProvider.ListRecycleRequest, _ ...grpc.CallOption) *apiProvider.ListRecycleResponse { + return &apiProvider.ListRecycleResponse{ + RecycleItems: recycleItems[req.Ref.ResourceId.OpaqueId], + } + }, nil, + ) + gwc.On("PurgeRecycle", mock.Anything, mock.Anything).Return( + func(_ context.Context, req *apiProvider.PurgeRecycleRequest, _ ...grpc.CallOption) *apiProvider.PurgeRecycleResponse { + var items []*apiProvider.RecycleItem + for _, item := range recycleItems[req.Ref.ResourceId.OpaqueId] { + if req.Key == item.Key { + continue + } + + items = append(items, item) + } + + recycleItems[req.Ref.ResourceId.OpaqueId] = items + + return &apiProvider.PurgeRecycleResponse{ + Status: &apiRpc.Status{ + Code: apiRpc.Code_CODE_OK, + }, + } + }, nil, + ) + + err := task.PurgeTrashBin(user.Id, now, task.Project, gwc, "") + Expect(err).To(BeNil()) + Expect(recycleItems["personal"]).To(HaveLen(2)) + Expect(recycleItems["project"]).To(HaveLen(2)) + // virtual spaces are ignored + Expect(recycleItems["virtual"]).To(HaveLen(3)) + }) + }) +})