graph: add restore, permanentDelete, purge and empty for the recycle bin

This commit is contained in:
Dominik Schmidt committed 2026-09-07 13:03:24 +02:00
1 parent fdebaba41d
commit cdbd75b086
3 files changed
+665

No files matched your search

@@ -0,0 +1,299 @@
package svc
import (
"errors"
"io"
"net/http"
"path"
cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
"github.com/opencloud-eu/reva/v2/pkg/utils"
"github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode"
)
// restoreRequest is the optional body of driveItem: restore
type restoreRequest struct {
ParentReference *libregraph.ItemReference `json:"parentReference,omitempty"`
Name *string `json:"name,omitempty"`
}
// RestoreDriveItem restores a trashed item, by default to its original location.
// The item id carries the recycle key, see recycleItemToDriveItem.
func (g Graph) RestoreDriveItem(w http.ResponseWriter, r *http.Request) {
g.logger.Debug().Msg("Calling RestoreDriveItem")
ctx := r.Context()
itemID, ok := parseTrashItemID(w, r)
if !ok {
return
}
var body restoreRequest
if err := StrictJSONUnmarshal(r.Body, &body); err != nil && !errors.Is(err, io.EOF) {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid body: "+err.Error())
return
}
// the listing gives us the original location, needed for the default target and the response
key := itemID.GetOpaqueId()
items, ok := g.listRecycle(w, r, itemID, key)
if !ok {
return
}
var trashed *storageprovider.RecycleItem
for _, item := range items {
if item.GetKey() == key {
trashed = item
}
}
if trashed == nil {
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "item not found")
return
}
target, ok := restoreTarget(w, r, itemID, trashed, body)
if !ok {
return
}
gatewayClient, ok := g.GetGatewayClient(w, r)
if !ok {
return
}
res, err := gatewayClient.RestoreRecycleItem(ctx, &storageprovider.RestoreRecycleItemRequest{
Ref: &storageprovider.Reference{ResourceId: spaceRootID(itemID)},
Key: key,
RestoreRef: target,
})
if err != nil {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
if !renderTrashStatus(w, r, res.GetStatus()) {
return
}
statRes, err := gatewayClient.Stat(ctx, &storageprovider.StatRequest{Ref: target})
switch {
case err != nil:
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
case statRes.GetStatus().GetCode() != cs3rpc.Code_CODE_OK:
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "restored, but could not stat the item: "+statRes.GetStatus().GetMessage())
return
}
driveItem, err := cs3ResourceToDriveItem(g.logger, g.publicBaseURL, statRes.GetInfo())
if err != nil {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, driveItem)
}
// restoreTarget builds the restore reference: the original location unless the body moves the item
func restoreTarget(w http.ResponseWriter, r *http.Request, itemID *storageprovider.ResourceId, trashed *storageprovider.RecycleItem, body restoreRequest) (*storageprovider.Reference, bool) {
origin := trashed.GetRef().GetPath()
name := path.Base(origin)
if body.Name != nil && *body.Name != "" {
name = *body.Name
}
if name != path.Base(name) || name == "." || name == "/" {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid name")
return nil, false
}
parent := body.ParentReference
if parent != nil && parent.DriveId != nil {
parentDrive, err := storagespace.ParseID(parent.GetDriveId())
if err != nil || parentDrive.GetStorageId() != itemID.GetStorageId() || parentDrive.GetSpaceId() != itemID.GetSpaceId() {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "restore into another drive is not supported")
return nil, false
}
}
switch {
case parent != nil && parent.Id != nil:
parentID, err := storagespace.ParseID(parent.GetId())
if err != nil || parentID.GetStorageId() != itemID.GetStorageId() || parentID.GetSpaceId() != itemID.GetSpaceId() {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid parentReference.id")
return nil, false
}
return &storageprovider.Reference{ResourceId: &parentID, Path: utils.MakeRelativePath(name)}, true
case parent != nil && parent.Path != nil:
return &storageprovider.Reference{
ResourceId: spaceRootID(itemID),
Path: utils.MakeRelativePath(path.Join(parent.GetPath(), name)),
}, true
default:
return &storageprovider.Reference{
ResourceId: spaceRootID(itemID),
Path: utils.MakeRelativePath(path.Join(path.Dir(origin), name)),
}, true
}
}
// PermanentDeleteDriveItem deletes a live item and purges it from the trash right away.
// reva has no delete that bypasses the trash, so this is two steps; the trash key of a
// freshly deleted item is its node id in both decomposedfs and posixfs.
func (g Graph) PermanentDeleteDriveItem(w http.ResponseWriter, r *http.Request) {
g.logger.Debug().Msg("Calling PermanentDeleteDriveItem")
ctx := r.Context()
itemID, ok := parseTrashItemID(w, r)
if !ok {
return
}
if IsSpaceRoot(itemID) {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "cannot delete the drive root")
return
}
if IsShareJail(itemID) {
// the trash of a shared item lives in the owner's space, which the share jail id does not tell us
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "not supported for shared items, use the item id of the owning drive")
return
}
gatewayClient, ok := g.GetGatewayClient(w, r)
if !ok {
return
}
delRes, err := gatewayClient.Delete(ctx, &storageprovider.DeleteRequest{
Ref: &storageprovider.Reference{ResourceId: itemID},
})
if err != nil {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
if !renderTrashStatus(w, r, delRes.GetStatus()) {
return
}
purgeRes, err := gatewayClient.PurgeRecycle(ctx, &storageprovider.PurgeRecycleRequest{
Ref: &storageprovider.Reference{ResourceId: spaceRootID(itemID)},
Key: itemID.GetOpaqueId(),
})
if err != nil {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "deleted, but could not purge from the trash: "+err.Error())
return
}
if purgeRes.GetStatus().GetCode() != cs3rpc.Code_CODE_OK {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "deleted, but could not purge from the trash: "+purgeRes.GetStatus().GetMessage())
return
}
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
// DeleteDriveSpecialItem purges one trashed item
func (g Graph) DeleteDriveSpecialItem(w http.ResponseWriter, r *http.Request) {
g.logger.Debug().Msg("Calling DeleteDriveSpecialItem")
driveID, ok := parseSpecialParams(w, r)
if !ok {
return
}
itemID, ok := parseTrashItemID(w, r)
if !ok {
return
}
g.purgeRecycle(w, r, &driveID, itemID.GetOpaqueId())
}
// EmptyDriveSpecial purges the whole trash. In the colon path form
// (special/recyclebin:/{key}) it purges only that item.
func (g Graph) EmptyDriveSpecial(w http.ResponseWriter, r *http.Request) {
g.logger.Debug().Msg("Calling EmptyDriveSpecial")
driveID, ok := parseSpecialParams(w, r)
if !ok {
return
}
g.purgeRecycle(w, r, &driveID, specialFolderKey(r))
}
// purgeRecycle purges the key, or the whole trash for an empty key
func (g Graph) purgeRecycle(w http.ResponseWriter, r *http.Request, driveID *storageprovider.ResourceId, key string) {
gatewayClient, ok := g.GetGatewayClient(w, r)
if !ok {
return
}
res, err := gatewayClient.PurgeRecycle(r.Context(), &storageprovider.PurgeRecycleRequest{
Ref: &storageprovider.Reference{ResourceId: spaceRootID(driveID)},
Key: key,
})
if err != nil {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
if !renderTrashStatus(w, r, res.GetStatus()) {
return
}
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
// parseTrashItemID reads the item id, which the v1.0 and v1beta1 routes bind under different names,
// and checks it against the drive id when the route has one
func parseTrashItemID(w http.ResponseWriter, r *http.Request) (*storageprovider.ResourceId, bool) {
param := "itemID"
if chi.URLParam(r, param) == "" {
param = "driveItemID"
}
itemID, err := parseIDParam(r, param)
if err != nil {
errorcode.RenderError(w, r, err)
return nil, false
}
if itemID.GetOpaqueId() == "" {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid itemID")
return nil, false
}
if chi.URLParam(r, "driveID") != "" {
driveID, err := parseIDParam(r, "driveID")
if err != nil {
errorcode.RenderError(w, r, err)
return nil, false
}
if driveID.GetStorageId() != itemID.GetStorageId() || driveID.GetSpaceId() != itemID.GetSpaceId() {
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "item not found")
return nil, false
}
}
return &itemID, true
}
// renderTrashStatus maps a mutating trash call's status; true means OK. Unlike the listing,
// PERMISSION_DENIED is a 403 here: the caller could already see the item.
func renderTrashStatus(w http.ResponseWriter, r *http.Request, st *cs3rpc.Status) bool {
switch st.GetCode() {
case cs3rpc.Code_CODE_OK:
return true
case cs3rpc.Code_CODE_NOT_FOUND:
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, st.GetMessage())
case cs3rpc.Code_CODE_PERMISSION_DENIED:
errorcode.AccessDenied.Render(w, r, http.StatusForbidden, st.GetMessage())
case cs3rpc.Code_CODE_UNAUTHENTICATED:
errorcode.Unauthenticated.Render(w, r, http.StatusUnauthorized, st.GetMessage())
case cs3rpc.Code_CODE_ALREADY_EXISTS:
errorcode.NameAlreadyExists.Render(w, r, http.StatusConflict, st.GetMessage())
case cs3rpc.Code_CODE_LOCKED, cs3rpc.Code_CODE_FAILED_PRECONDITION:
errorcode.ItemIsLocked.Render(w, r, http.StatusLocked, st.GetMessage())
default:
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, st.GetMessage())
}
return false
}
@@ -0,0 +1,354 @@
package svc_test
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/go-chi/chi/v5"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/mock"
"google.golang.org/grpc"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/status"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/pkg/shared"
"github.com/opencloud-eu/opencloud/services/graph/mocks"
"github.com/opencloud-eu/opencloud/services/graph/pkg/config"
"github.com/opencloud-eu/opencloud/services/graph/pkg/config/defaults"
identitymocks "github.com/opencloud-eu/opencloud/services/graph/pkg/identity/mocks"
"github.com/opencloud-eu/opencloud/services/graph/pkg/metrics"
graphm "github.com/opencloud-eu/opencloud/services/graph/pkg/middleware"
service "github.com/opencloud-eu/opencloud/services/graph/pkg/service/v0"
)
var _ = Describe("Drive item trash operations", func() {
var (
svc service.Service
ctx context.Context
cfg *config.Config
gatewayClient *cs3mocks.GatewayAPIClient
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
eventsPublisher mocks.Publisher
identityBackend *identitymocks.Backend
rr *httptest.ResponseRecorder
currentUser = &userpb.User{Id: &userpb.UserId{OpaqueId: "user"}}
)
type params map[string]string
// newRequest binds the given chi params; body is sent as the JSON request body
newRequest := func(method, url string, p params, body string) *http.Request {
r := httptest.NewRequest(method, url, strings.NewReader(body))
rctx := chi.NewRouteContext()
for k, v := range p {
rctx.URLParams.Add(k, v)
}
return r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
}
trashedFile := &provider.RecycleItem{
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
Key: "nodeid",
Ref: &provider.Reference{Path: "/Documents/notes.txt"},
}
mockListRecycle := func(items ...*provider.RecycleItem) {
gatewayClient.On("ListRecycle", mock.Anything, mock.Anything).Return(&provider.ListRecycleResponse{
Status: status.NewOK(ctx),
RecycleItems: items,
}, nil)
}
mockRestore := func(st *cs3rpc.Status) **provider.RestoreRecycleItemRequest {
var req *provider.RestoreRecycleItemRequest
gatewayClient.On("RestoreRecycleItem", mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
req = args.Get(1).(*provider.RestoreRecycleItemRequest)
}).Return(&provider.RestoreRecycleItemResponse{Status: st}, nil)
return &req
}
mockStatOK := func() {
gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(&provider.StatResponse{
Status: status.NewOK(ctx),
Info: &provider.ResourceInfo{
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
Id: &provider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "nodeid"},
Path: "./Documents/notes.txt",
},
}, nil)
}
mockPurge := func(st *cs3rpc.Status) **provider.PurgeRecycleRequest {
var req *provider.PurgeRecycleRequest
gatewayClient.On("PurgeRecycle", mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
req = args.Get(1).(*provider.PurgeRecycleRequest)
}).Return(&provider.PurgeRecycleResponse{Status: st}, nil)
return &req
}
BeforeEach(func() {
eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil)
pool.RemoveSelector("GatewaySelector" + "eu.opencloud.api.gateway")
gatewayClient = &cs3mocks.GatewayAPIClient{}
gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient](
"GatewaySelector",
"eu.opencloud.api.gateway",
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
return gatewayClient
},
)
logger := log.NewLogger()
identityBackend = &identitymocks.Backend{}
metrics, _ := metrics.New(prometheus.NewRegistry(), &logger, func([]string) (string, string) { return "", "" })
rr = httptest.NewRecorder()
ctx = context.Background()
cfg = defaults.FullDefaultConfig()
cfg.Identity.LDAP.CACert = ""
cfg.TokenManager.JWTSecret = "loremipsum"
cfg.Commons = &shared.Commons{}
cfg.GRPCClientTLS = &shared.GRPCClientTLS{}
var err error
svc, err = service.NewService(
service.Config(cfg),
service.Metrics(metrics),
service.WithGatewaySelector(gatewaySelector),
service.EventsPublisher(&eventsPublisher),
service.WithIdentityBackend(identityBackend),
)
Expect(err).ToNot(HaveOccurred())
})
Describe("RestoreDriveItem", func() {
v1Params := params{"driveID": "storageid$spaceid", "driveItemID": "storageid$spaceid!nodeid"}
It("rejects an item from another drive", func() {
svc.RestoreDriveItem(rr, newRequest(http.MethodPost, "/", params{"driveID": "storageid$other", "driveItemID": "storageid$spaceid!nodeid"}, ""))
Expect(rr.Code).To(Equal(http.StatusNotFound))
gatewayClient.AssertNotCalled(GinkgoT(), "ListRecycle", mock.Anything, mock.Anything)
})
It("rejects a malformed body", func() {
svc.RestoreDriveItem(rr, newRequest(http.MethodPost, "/", v1Params, `{"nope": 1}`))
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("returns not found for an unknown key", func() {
mockListRecycle()
svc.RestoreDriveItem(rr, newRequest(http.MethodPost, "/", v1Params, ""))
Expect(rr.Code).To(Equal(http.StatusNotFound))
gatewayClient.AssertNotCalled(GinkgoT(), "RestoreRecycleItem", mock.Anything, mock.Anything)
})
It("restores to the original location by default and returns the item", func() {
mockListRecycle(trashedFile)
req := mockRestore(status.NewOK(ctx))
mockStatOK()
svc.RestoreDriveItem(rr, newRequest(http.MethodPost, "/", v1Params, ""))
Expect(rr.Code).To(Equal(http.StatusOK))
Expect((*req).GetKey()).To(Equal("nodeid"))
Expect((*req).GetRef().GetResourceId().GetOpaqueId()).To(Equal("spaceid"))
Expect((*req).GetRestoreRef().GetResourceId().GetOpaqueId()).To(Equal("spaceid"))
Expect((*req).GetRestoreRef().GetPath()).To(Equal("./Documents/notes.txt"))
var item libregraph.DriveItem
Expect(json.Unmarshal(rr.Body.Bytes(), &item)).To(Succeed())
Expect(item.GetId()).To(Equal("storageid$spaceid!nodeid"))
Expect(item.GetName()).To(Equal("notes.txt"))
})
It("accepts the v1beta1 itemID param", func() {
mockListRecycle(trashedFile)
mockRestore(status.NewOK(ctx))
mockStatOK()
svc.RestoreDriveItem(rr, newRequest(http.MethodPost, "/", params{"driveID": "storageid$spaceid", "itemID": "storageid$spaceid!nodeid"}, ""))
Expect(rr.Code).To(Equal(http.StatusOK))
})
It("works without a drive id (me/drive)", func() {
mockListRecycle(trashedFile)
mockRestore(status.NewOK(ctx))
mockStatOK()
svc.RestoreDriveItem(rr, newRequest(http.MethodPost, "/", params{"itemID": "storageid$spaceid!nodeid"}, ""))
Expect(rr.Code).To(Equal(http.StatusOK))
})
It("restores into the given parent with a new name", func() {
mockListRecycle(trashedFile)
req := mockRestore(status.NewOK(ctx))
mockStatOK()
body := `{"parentReference": {"id": "storageid$spaceid!parentid"}, "name": "renamed.txt"}`
svc.RestoreDriveItem(rr, newRequest(http.MethodPost, "/", v1Params, body))
Expect(rr.Code).To(Equal(http.StatusOK))
Expect((*req).GetRestoreRef().GetResourceId().GetOpaqueId()).To(Equal("parentid"))
Expect((*req).GetRestoreRef().GetPath()).To(Equal("./renamed.txt"))
})
It("restores into a parent given by path", func() {
mockListRecycle(trashedFile)
req := mockRestore(status.NewOK(ctx))
mockStatOK()
body := `{"parentReference": {"path": "/Archive/2025"}}`
svc.RestoreDriveItem(rr, newRequest(http.MethodPost, "/", v1Params, body))
Expect(rr.Code).To(Equal(http.StatusOK))
Expect((*req).GetRestoreRef().GetResourceId().GetOpaqueId()).To(Equal("spaceid"))
Expect((*req).GetRestoreRef().GetPath()).To(Equal("./Archive/2025/notes.txt"))
})
It("only renames when just a name is given", func() {
mockListRecycle(trashedFile)
req := mockRestore(status.NewOK(ctx))
mockStatOK()
svc.RestoreDriveItem(rr, newRequest(http.MethodPost, "/", v1Params, `{"name": "renamed.txt"}`))
Expect(rr.Code).To(Equal(http.StatusOK))
Expect((*req).GetRestoreRef().GetPath()).To(Equal("./Documents/renamed.txt"))
})
It("rejects a name with a slash", func() {
mockListRecycle(trashedFile)
svc.RestoreDriveItem(rr, newRequest(http.MethodPost, "/", v1Params, `{"name": "a/b.txt"}`))
Expect(rr.Code).To(Equal(http.StatusBadRequest))
gatewayClient.AssertNotCalled(GinkgoT(), "RestoreRecycleItem", mock.Anything, mock.Anything)
})
It("rejects a parent in another drive", func() {
mockListRecycle(trashedFile)
body := `{"parentReference": {"driveId": "storageid$other", "id": "storageid$other!parentid"}}`
svc.RestoreDriveItem(rr, newRequest(http.MethodPost, "/", v1Params, body))
Expect(rr.Code).To(Equal(http.StatusBadRequest))
gatewayClient.AssertNotCalled(GinkgoT(), "RestoreRecycleItem", mock.Anything, mock.Anything)
})
DescribeTable("maps restore errors",
func(st *cs3rpc.Status, code int) {
mockListRecycle(trashedFile)
mockRestore(st)
svc.RestoreDriveItem(rr, newRequest(http.MethodPost, "/", v1Params, ""))
Expect(rr.Code).To(Equal(code))
gatewayClient.AssertNotCalled(GinkgoT(), "Stat", mock.Anything, mock.Anything)
},
Entry("already exists", status.NewAlreadyExists(context.Background(), errors.New("exists"), "exists"), http.StatusConflict),
Entry("permission denied", status.NewPermissionDenied(context.Background(), errors.New("denied"), "denied"), http.StatusForbidden),
Entry("not found", status.NewNotFound(context.Background(), "gone"), http.StatusNotFound),
Entry("internal", status.NewInternal(context.Background(), "internal"), http.StatusInternalServerError),
)
})
Describe("PermanentDeleteDriveItem", func() {
v1Params := params{"driveID": "storageid$spaceid", "driveItemID": "storageid$spaceid!nodeid"}
It("refuses the drive root", func() {
svc.PermanentDeleteDriveItem(rr, newRequest(http.MethodPost, "/", params{"driveID": "storageid$spaceid", "driveItemID": "storageid$spaceid!spaceid"}, ""))
Expect(rr.Code).To(Equal(http.StatusBadRequest))
gatewayClient.AssertNotCalled(GinkgoT(), "Delete", mock.Anything, mock.Anything)
})
It("refuses share jail items", func() {
p := params{"driveID": "a0ca6a90-a365-4782-871e-d44447bbc668$a0ca6a90-a365-4782-871e-d44447bbc668", "driveItemID": "a0ca6a90-a365-4782-871e-d44447bbc668$a0ca6a90-a365-4782-871e-d44447bbc668!shareid"}
svc.PermanentDeleteDriveItem(rr, newRequest(http.MethodPost, "/", p, ""))
Expect(rr.Code).To(Equal(http.StatusBadRequest))
gatewayClient.AssertNotCalled(GinkgoT(), "Delete", mock.Anything, mock.Anything)
})
It("deletes and purges the trash key", func() {
var delReq *provider.DeleteRequest
gatewayClient.On("Delete", mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
delReq = args.Get(1).(*provider.DeleteRequest)
}).Return(&provider.DeleteResponse{Status: status.NewOK(ctx)}, nil)
purge := mockPurge(status.NewOK(ctx))
svc.PermanentDeleteDriveItem(rr, newRequest(http.MethodPost, "/", v1Params, ""))
Expect(rr.Code).To(Equal(http.StatusNoContent))
Expect(delReq.GetRef().GetResourceId().GetOpaqueId()).To(Equal("nodeid"))
Expect((*purge).GetRef().GetResourceId().GetOpaqueId()).To(Equal("spaceid"))
Expect((*purge).GetKey()).To(Equal("nodeid"))
})
It("maps a failed delete and does not purge", func() {
gatewayClient.On("Delete", mock.Anything, mock.Anything).Return(&provider.DeleteResponse{Status: status.NewNotFound(ctx, "gone")}, nil)
svc.PermanentDeleteDriveItem(rr, newRequest(http.MethodPost, "/", v1Params, ""))
Expect(rr.Code).To(Equal(http.StatusNotFound))
gatewayClient.AssertNotCalled(GinkgoT(), "PurgeRecycle", mock.Anything, mock.Anything)
})
It("reports a failed purge after a successful delete", func() {
gatewayClient.On("Delete", mock.Anything, mock.Anything).Return(&provider.DeleteResponse{Status: status.NewOK(ctx)}, nil)
mockPurge(status.NewInternal(ctx, "boom"))
svc.PermanentDeleteDriveItem(rr, newRequest(http.MethodPost, "/", v1Params, ""))
Expect(rr.Code).To(Equal(http.StatusInternalServerError))
})
})
Describe("DeleteDriveSpecialItem", func() {
It("rejects an unknown special folder", func() {
svc.DeleteDriveSpecialItem(rr, newRequest(http.MethodDelete, "/", params{"driveID": "storageid$spaceid", "specialName": "nope", "itemID": "storageid$spaceid!nodeid"}, ""))
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("rejects an item from another drive", func() {
svc.DeleteDriveSpecialItem(rr, newRequest(http.MethodDelete, "/", params{"driveID": "storageid$spaceid", "specialName": "recyclebin", "itemID": "storageid$other!nodeid"}, ""))
Expect(rr.Code).To(Equal(http.StatusNotFound))
gatewayClient.AssertNotCalled(GinkgoT(), "PurgeRecycle", mock.Anything, mock.Anything)
})
It("purges the key", func() {
purge := mockPurge(status.NewOK(ctx))
svc.DeleteDriveSpecialItem(rr, newRequest(http.MethodDelete, "/", params{"driveID": "storageid$spaceid", "specialName": "recyclebin", "itemID": "storageid$spaceid!nodeid/sub/file"}, ""))
Expect(rr.Code).To(Equal(http.StatusNoContent))
Expect((*purge).GetKey()).To(Equal("nodeid/sub/file"))
Expect((*purge).GetRef().GetResourceId().GetOpaqueId()).To(Equal("spaceid"))
})
It("maps permission denied to forbidden", func() {
mockPurge(status.NewPermissionDenied(ctx, errors.New("denied"), "denied"))
svc.DeleteDriveSpecialItem(rr, newRequest(http.MethodDelete, "/", params{"driveID": "storageid$spaceid", "specialName": "recyclebin", "itemID": "storageid$spaceid!nodeid"}, ""))
Expect(rr.Code).To(Equal(http.StatusForbidden))
})
})
Describe("EmptyDriveSpecial", func() {
It("purges the whole trash", func() {
purge := mockPurge(status.NewOK(ctx))
svc.EmptyDriveSpecial(rr, newRequest(http.MethodDelete, "/", params{"driveID": "storageid$spaceid", "specialName": "recyclebin"}, ""))
Expect(rr.Code).To(Equal(http.StatusNoContent))
Expect((*purge).GetKey()).To(Equal(""))
})
It("purges only the addressed key in the colon path form", func() {
purge := mockPurge(status.NewOK(ctx))
r := newRequest(http.MethodDelete, "/", params{"driveID": "storageid$spaceid", "specialName": "recyclebin"}, "")
r = r.WithContext(graphm.WithSpecialFolderPath(r.Context(), "/nodeid"))
svc.EmptyDriveSpecial(rr, r)
Expect(rr.Code).To(Equal(http.StatusNoContent))
Expect((*purge).GetKey()).To(Equal("nodeid"))
})
})
})
+12
View File
@@ -106,6 +106,10 @@ type Service interface { //nolint:interfacebloat
GetDriveItemChildren(w http.ResponseWriter, r *http.Request)
GetDriveSpecial(w http.ResponseWriter, r *http.Request)
ListDriveSpecialChildren(w http.ResponseWriter, r *http.Request)
EmptyDriveSpecial(w http.ResponseWriter, r *http.Request)
DeleteDriveSpecialItem(w http.ResponseWriter, r *http.Request)
RestoreDriveItem(w http.ResponseWriter, r *http.Request)
PermanentDeleteDriveItem(w http.ResponseWriter, r *http.Request)
CreateUploadSession(w http.ResponseWriter, r *http.Request)
@@ -273,6 +277,8 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx
r.Get("/", drivesDriveItemApi.GetDriveItem)
r.Patch("/", drivesDriveItemApi.UpdateDriveItem)
r.Delete("/", drivesDriveItemApi.DeleteDriveItem)
r.Post("/restore", svc.RestoreDriveItem)
r.Post("/permanentDelete", svc.PermanentDeleteDriveItem)
r.Post("/invite", driveItemPermissionsApi.Invite)
r.Post("/createLink", driveItemPermissionsApi.CreateLink)
r.Route("/permissions", func(r chi.Router) {
@@ -308,6 +314,8 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx
r.Get("/", svc.GetUserDrive)
r.Get("/root/children", svc.GetRootDriveChildren)
r.Post("/items/{itemID}/follow", svc.FollowDriveItem)
r.Post("/items/{itemID}/restore", svc.RestoreDriveItem)
r.Post("/items/{itemID}/permanentDelete", svc.PermanentDeleteDriveItem)
r.Delete("/following/{itemID}", svc.UnfollowDriveItem)
})
r.Get("/drives", svc.GetDrives(APIVersion_1))
@@ -372,10 +380,14 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx
r.Get("/", svc.GetDriveItem)
r.Get("/children", svc.GetDriveItemChildren)
r.Post("/createUploadSession", svc.CreateUploadSession)
r.Post("/restore", svc.RestoreDriveItem)
r.Post("/permanentDelete", svc.PermanentDeleteDriveItem)
})
r.Route("/special/{specialName}", func(r chi.Router) {
r.Get("/", svc.GetDriveSpecial)
r.Delete("/", svc.EmptyDriveSpecial)
r.Get("/children", svc.ListDriveSpecialChildren)
r.Delete("/items/{itemID}", svc.DeleteDriveSpecialItem)
})
})
})