Compare commits

...
Author SHA1 Message Date
Dominik Schmidt 9322750a65 graph: inline the children-POST defer condition at its use 2026-09-03 03:49:03 +02:00
Dominik Schmidt 8ab026cb65 graph: defer colon-path resolution for POST children to the handler 2026-09-03 03:49:03 +02:00
Dominik Schmidt 46717d6227 graph: keep colon-path middleware side-effect free, 201 on creation, shared facet check 2026-09-03 03:49:03 +02:00
Dominik Schmidt 8aeab6bca7 graph: implement driveItem creation under drives/items/children
Implements the CreateDriveItem/CreateChildDriveItem operations from
libre-graph-api#42 including @libre.graph.conflictBehavior and
@libre.graph.missingParentsBehavior.
2026-09-03 03:49:03 +02:00
6 changed files with 1070 additions and 33 deletions

No files matched your search

@@ -41,6 +41,172 @@ func (_m *DrivesDriveItemProvider) EXPECT() *DrivesDriveItemProvider_Expecter {
return &DrivesDriveItemProvider_Expecter{mock: &_m.Mock}
}
// CreateChild provides a mock function for the type DrivesDriveItemProvider
func (_mock *DrivesDriveItemProvider) CreateChild(ctx context.Context, parentID *providerv1beta1.ResourceId, name string, isFolder bool, replace bool) (*providerv1beta1.ResourceInfo, error) {
ret := _mock.Called(ctx, parentID, name, isFolder, replace)
if len(ret) == 0 {
panic("no return value specified for CreateChild")
}
var r0 *providerv1beta1.ResourceInfo
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, *providerv1beta1.ResourceId, string, bool, bool) (*providerv1beta1.ResourceInfo, error)); ok {
return returnFunc(ctx, parentID, name, isFolder, replace)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, *providerv1beta1.ResourceId, string, bool, bool) *providerv1beta1.ResourceInfo); ok {
r0 = returnFunc(ctx, parentID, name, isFolder, replace)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*providerv1beta1.ResourceInfo)
}
}
if returnFunc, ok := ret.Get(1).(func(context.Context, *providerv1beta1.ResourceId, string, bool, bool) error); ok {
r1 = returnFunc(ctx, parentID, name, isFolder, replace)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// DrivesDriveItemProvider_CreateChild_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateChild'
type DrivesDriveItemProvider_CreateChild_Call struct {
*mock.Call
}
// CreateChild is a helper method to define mock.On call
// - ctx context.Context
// - parentID *providerv1beta1.ResourceId
// - name string
// - isFolder bool
// - replace bool
func (_e *DrivesDriveItemProvider_Expecter) CreateChild(ctx interface{}, parentID interface{}, name interface{}, isFolder interface{}, replace interface{}) *DrivesDriveItemProvider_CreateChild_Call {
return &DrivesDriveItemProvider_CreateChild_Call{Call: _e.mock.On("CreateChild", ctx, parentID, name, isFolder, replace)}
}
func (_c *DrivesDriveItemProvider_CreateChild_Call) Run(run func(ctx context.Context, parentID *providerv1beta1.ResourceId, name string, isFolder bool, replace bool)) *DrivesDriveItemProvider_CreateChild_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 *providerv1beta1.ResourceId
if args[1] != nil {
arg1 = args[1].(*providerv1beta1.ResourceId)
}
var arg2 string
if args[2] != nil {
arg2 = args[2].(string)
}
var arg3 bool
if args[3] != nil {
arg3 = args[3].(bool)
}
var arg4 bool
if args[4] != nil {
arg4 = args[4].(bool)
}
run(
arg0,
arg1,
arg2,
arg3,
arg4,
)
})
return _c
}
func (_c *DrivesDriveItemProvider_CreateChild_Call) Return(resourceInfo *providerv1beta1.ResourceInfo, err error) *DrivesDriveItemProvider_CreateChild_Call {
_c.Call.Return(resourceInfo, err)
return _c
}
func (_c *DrivesDriveItemProvider_CreateChild_Call) RunAndReturn(run func(ctx context.Context, parentID *providerv1beta1.ResourceId, name string, isFolder bool, replace bool) (*providerv1beta1.ResourceInfo, error)) *DrivesDriveItemProvider_CreateChild_Call {
_c.Call.Return(run)
return _c
}
// ResolvePath provides a mock function for the type DrivesDriveItemProvider
func (_mock *DrivesDriveItemProvider) ResolvePath(ctx context.Context, parentID *providerv1beta1.ResourceId, relPath string, createMissing bool) (*providerv1beta1.ResourceId, error) {
ret := _mock.Called(ctx, parentID, relPath, createMissing)
if len(ret) == 0 {
panic("no return value specified for ResolvePath")
}
var r0 *providerv1beta1.ResourceId
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, *providerv1beta1.ResourceId, string, bool) (*providerv1beta1.ResourceId, error)); ok {
return returnFunc(ctx, parentID, relPath, createMissing)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, *providerv1beta1.ResourceId, string, bool) *providerv1beta1.ResourceId); ok {
r0 = returnFunc(ctx, parentID, relPath, createMissing)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*providerv1beta1.ResourceId)
}
}
if returnFunc, ok := ret.Get(1).(func(context.Context, *providerv1beta1.ResourceId, string, bool) error); ok {
r1 = returnFunc(ctx, parentID, relPath, createMissing)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// DrivesDriveItemProvider_ResolvePath_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolvePath'
type DrivesDriveItemProvider_ResolvePath_Call struct {
*mock.Call
}
// ResolvePath is a helper method to define mock.On call
// - ctx context.Context
// - parentID *providerv1beta1.ResourceId
// - relPath string
// - createMissing bool
func (_e *DrivesDriveItemProvider_Expecter) ResolvePath(ctx interface{}, parentID interface{}, relPath interface{}, createMissing interface{}) *DrivesDriveItemProvider_ResolvePath_Call {
return &DrivesDriveItemProvider_ResolvePath_Call{Call: _e.mock.On("ResolvePath", ctx, parentID, relPath, createMissing)}
}
func (_c *DrivesDriveItemProvider_ResolvePath_Call) Run(run func(ctx context.Context, parentID *providerv1beta1.ResourceId, relPath string, createMissing bool)) *DrivesDriveItemProvider_ResolvePath_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 *providerv1beta1.ResourceId
if args[1] != nil {
arg1 = args[1].(*providerv1beta1.ResourceId)
}
var arg2 string
if args[2] != nil {
arg2 = args[2].(string)
}
var arg3 bool
if args[3] != nil {
arg3 = args[3].(bool)
}
run(
arg0,
arg1,
arg2,
arg3,
)
})
return _c
}
func (_c *DrivesDriveItemProvider_ResolvePath_Call) Return(resourceId *providerv1beta1.ResourceId, err error) *DrivesDriveItemProvider_ResolvePath_Call {
_c.Call.Return(resourceId, err)
return _c
}
func (_c *DrivesDriveItemProvider_ResolvePath_Call) RunAndReturn(run func(ctx context.Context, parentID *providerv1beta1.ResourceId, relPath string, createMissing bool) (*providerv1beta1.ResourceId, error)) *DrivesDriveItemProvider_ResolvePath_Call {
_c.Call.Return(run)
return _c
}
// GetShare provides a mock function for the type DrivesDriveItemProvider
func (_mock *DrivesDriveItemProvider) GetShare(ctx context.Context, shareID *collaborationv1beta1.ShareId) (*collaborationv1beta1.ReceivedShare, error) {
ret := _mock.Called(ctx, shareID)
+45 -12
View File
@@ -36,6 +36,17 @@ type contextKey string
// tracing/logging consumers.
const OriginalPathContextKey contextKey = "graph.original_path"
// ParentPathContextKey holds the unresolved parent path of a POST .../children
// colon request; the handler resolves (or creates) it after validation.
const ParentPathContextKey contextKey = "graph.parent_path"
// ParentPath returns the parent path stored by ResolveGraphPath, or "" for all
// other requests.
func ParentPath(ctx context.Context) string {
path, _ := ctx.Value(ParentPathContextKey).(string)
return path
}
// Sentinels distinguishing the resolution outcomes that map to specific HTTP
// statuses. Anything else surfaces as 500.
//
@@ -74,6 +85,10 @@ var (
// failures (gateway selection, RPC transport, unexpected status) surface
// as 5xx so outages aren't masked.
//
// POST .../children colon requests are never resolved here: the rewrite targets
// the anchor item and the parent path reaches the handler via
// ParentPathContextKey (see createChild).
//
// Requests whose RoutePath contains no colon fast-path through untouched.
func ResolveGraphPath(gws pool.Selectable[gateway.GatewayAPIClient], logger log.Logger) func(http.Handler) http.Handler {
l := logger.With().Str("middleware", "graphPathLookup").Logger()
@@ -91,7 +106,7 @@ func ResolveGraphPath(gws pool.Selectable[gateway.GatewayAPIClient], logger log.
driveID := chi.URLParam(r, "driveID")
original := r.URL.Path
rewritten, err := rewriteColonPath(r.Context(), gws, l, driveID, rctx.RoutePath)
rewritten, parentPath, err := rewriteColonPath(r.Context(), gws, l, driveID, rctx.RoutePath, r.Method)
switch {
case errors.Is(err, errPathNotFound):
l.Debug().Str("original", original).Msg("colon-path resolution: not found")
@@ -126,6 +141,9 @@ func ResolveGraphPath(gws pool.Selectable[gateway.GatewayAPIClient], logger log.
// tracing/logging. r.URL.Path itself stays untouched; only chi's
// internal RoutePath is rewritten.
r = r.WithContext(context.WithValue(r.Context(), OriginalPathContextKey, original))
if parentPath != "" {
r = r.WithContext(context.WithValue(r.Context(), ParentPathContextKey, parentPath))
}
rctx.RoutePath = rewritten
next.ServeHTTP(w, r)
})
@@ -145,7 +163,7 @@ type colonMatch struct {
suffix string // suffix with leading slash (e.g. "/children"); may be empty
}
// rewriteColonPath returns:
// rewriteColonPath returns (rewritten, parentPath, err):
// - "" + nil - no colon-syntax pattern matched (passthrough)
// - rewritten + nil - matched and resolved to a canonical RoutePath
// - "" + errPathNotFound - path doesn't exist or user lacks permission (404)
@@ -153,6 +171,10 @@ type colonMatch struct {
// - "" + errUnauthenticated - gateway said caller isn't authenticated (401)
// - "" + other error - operational / internal failure (5xx)
//
// parentPath is non-empty for POST .../children requests: their colon path is
// never resolved here, the rewrite targets the anchor item and the handler
// resolves (or creates) the parent path (see ParentPathContextKey).
//
// driveIDParam is the {driveID} route param (raw chi.URLParam value); routePath
// is chi.RouteContext().RoutePath (the part below /drives/{driveID}).
func rewriteColonPath(
@@ -161,10 +183,11 @@ func rewriteColonPath(
logger zerolog.Logger,
driveIDParam string,
routePath string,
) (string, error) {
method string,
) (string, string, error) {
match, ok := parseColonPath(routePath)
if !ok {
return "", nil
return "", "", nil
}
// RoutePath follows chi's RawPath, i.e. the percent-encoded wire form
@@ -174,7 +197,7 @@ func rewriteColonPath(
driveID, err := url.PathUnescape(driveIDParam)
if err != nil {
logger.Debug().Err(err).Str("driveID", driveIDParam).Msg("undecodable drive id in colon path")
return "", errInvalidRequest
return "", "", errInvalidRequest
}
anchorIDStr := driveID
@@ -182,7 +205,7 @@ func rewriteColonPath(
anchorIDStr, err = url.PathUnescape(match.itemAnchorID)
if err != nil {
logger.Debug().Err(err).Str("itemID", match.itemAnchorID).Msg("undecodable item id in colon path")
return "", errInvalidRequest
return "", "", errInvalidRequest
}
}
@@ -190,7 +213,7 @@ func rewriteColonPath(
if err != nil {
// Unparseable input is malformed by the client, not "not found".
logger.Debug().Err(err).Str("anchor", anchorIDStr).Msg("invalid anchor id in colon path")
return "", errInvalidRequest
return "", "", errInvalidRequest
}
// Item-anchored form: the itemID comes from the path, driveID from the
@@ -201,28 +224,38 @@ func rewriteColonPath(
drive, err := storagespace.ParseID(driveID)
if err != nil {
logger.Debug().Err(err).Str("driveID", driveID).Msg("invalid drive id in colon path")
return "", errInvalidRequest
return "", "", errInvalidRequest
}
if drive.GetStorageId() != anchor.GetStorageId() || drive.GetSpaceId() != anchor.GetSpaceId() {
logger.Debug().
Str("driveID", driveID).
Str("itemID", anchorIDStr).
Msg("drive id does not match item id storage/space")
return "", errInvalidRequest
return "", "", errInvalidRequest
}
}
relPath, err := url.PathUnescape(match.relPath)
if err != nil {
logger.Debug().Err(err).Str("relPath", match.relPath).Msg("undecodable path in colon path")
return "", errInvalidRequest
return "", "", errInvalidRequest
}
if method == http.MethodPost && match.suffix == "/children" {
// the colon path addresses the parent of the item to create: rewrite
// to the anchor item, the handler resolves the parent path
if anchor.GetOpaqueId() == "" {
// the space root item id is storage$space!space
anchor.OpaqueId = anchor.GetSpaceId()
}
return buildCanonicalRoutePath(storagespace.FormatResourceID(&anchor), match.suffix), relPath, nil
}
itemID, err := resolvePath(ctx, gws, &anchor, relPath)
if err != nil {
return "", err
return "", "", err
}
return buildCanonicalRoutePath(itemID, match.suffix), nil
return buildCanonicalRoutePath(itemID, match.suffix), "", nil
}
// parseColonPath splits a colon-syntax RoutePath (the part below
@@ -61,11 +61,12 @@ func statResponse(code cs3rpc.Code, withInfo bool) *storageprovider.StatResponse
// the middleware rewrote chi's route path correctly and the request reached the
// intended /items/{itemID}... handler with the resolved id bound as a param.
type leafCapture struct {
hit string // which leaf was reached ("" = none)
urlPath string // r.URL.Path as seen by the handler (must stay the original)
driveID string // chi.URLParam(driveID)
itemID string // resolved item id, decoded via PathUnescape
original any // OriginalPathContextKey value
hit string // which leaf was reached ("" = none)
urlPath string // r.URL.Path as seen by the handler (must stay the original)
driveID string // chi.URLParam(driveID)
itemID string // resolved item id, decoded via PathUnescape
original any // OriginalPathContextKey value
parentPath string // ParentPath(ctx) as seen by the handler
}
// newGraphTestRouter wires ResolveGraphPath into a chi router that mirrors the
@@ -94,6 +95,7 @@ func newGraphTestRouter(t *testing.T, gw *cs3mocks.GatewayAPIClient) (http.Handl
// mirror that here so we assert on the recovered id.
cap.itemID, _ = url.PathUnescape(raw)
cap.original = r.Context().Value(middleware.OriginalPathContextKey)
cap.parentPath = middleware.ParentPath(r.Context())
w.WriteHeader(http.StatusOK)
}
}
@@ -115,6 +117,7 @@ func newGraphTestRouter(t *testing.T, gw *cs3mocks.GatewayAPIClient) (http.Handl
r.Use(middleware.ResolveGraphPath(selector, log.NopLogger()))
r.Route("/items/{itemID}", func(r chi.Router) {
r.Get("/", leaf("item"))
r.Post("/children", leaf("createChild"))
r.Post("/createLink", leaf("createLink"))
r.Route("/permissions", func(r chi.Router) {
r.Get("/", leaf("permissions"))
@@ -469,3 +472,44 @@ func TestResolveGraphPath_OriginalPathContext(t *testing.T) {
assert.Equal(t, original, cap.original, "original URL must be available via OriginalPathContextKey")
assert.Equal(t, original, cap.urlPath, "r.URL.Path must remain the original request path")
}
// TestResolveGraphPath_DeferredChildrenPost pins that POST .../children colon
// requests are never resolved in the middleware: no CS3 calls, the rewrite
// targets the anchor item and the handler receives the parent path via
// ParentPathContextKey.
func TestResolveGraphPath_DeferredChildrenPost(t *testing.T) {
// the root-anchored form rewrites to the space root item id
rootItemID := testDriveID + "!f503f6fe-2656-4b0f-8289-fb3184962dfd"
t.Run("root-anchored rewrites to the root anchor and passes the path unresolved", func(t *testing.T) {
gw := cs3mocks.NewGatewayAPIClient(t) // no CS3 calls expected
router, cap := newGraphTestRouter(t, gw)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, httptest.NewRequest(
http.MethodPost, "/graph/v1beta1/drives/"+testDriveID+"/root:/a/b:/children", nil,
))
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, "createChild", cap.hit)
assert.Equal(t, rootItemID, cap.itemID)
assert.Equal(t, "/a/b", cap.parentPath)
})
t.Run("item-anchored rewrites to the item anchor and passes the path unresolved", func(t *testing.T) {
gw := cs3mocks.NewGatewayAPIClient(t) // no CS3 calls expected
router, cap := newGraphTestRouter(t, gw)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, httptest.NewRequest(
http.MethodPost,
"/graph/v1beta1/drives/"+testDriveID+"/items/"+testItemID+":/a/b:/children",
nil,
))
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, "createChild", cap.hit)
assert.Equal(t, testItemID, cap.itemID)
assert.Equal(t, "/a/b", cap.parentPath)
})
}
@@ -4,7 +4,9 @@ import (
"context"
"errors"
"net/http"
"net/url"
"path/filepath"
"strings"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
collaboration "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1"
@@ -20,6 +22,7 @@ import (
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode"
graphm "github.com/opencloud-eu/opencloud/services/graph/pkg/middleware"
)
const (
@@ -72,6 +75,24 @@ var (
// ErrAlreadyUnmounted is returned when all shares are already unmounted
ErrAlreadyUnmounted = errorcode.New(errorcode.NameAlreadyExists, "shares already unmounted")
// ErrInvalidItemName is returned when the name for a new drive item is invalid
ErrInvalidItemName = errorcode.New(errorcode.InvalidRequest, "invalid item name")
// ErrExactlyOneFacet is returned when not exactly one of the create facets is set
ErrExactlyOneFacet = errorcode.New(errorcode.InvalidRequest, "exactly one of folder, file or remoteItem must be set")
// ErrExactlyOneChildFacet is returned when not exactly one of the child create facets is set
ErrExactlyOneChildFacet = errorcode.New(errorcode.InvalidRequest, "exactly one of folder or file must be set")
// ErrInvalidConflictBehavior is returned when the conflictBehavior query parameter is invalid
ErrInvalidConflictBehavior = errorcode.New(errorcode.InvalidRequest, "invalid @libre.graph.conflictBehavior")
// ErrInvalidMissingParentsBehavior is returned when the missingParentsBehavior query parameter is invalid
ErrInvalidMissingParentsBehavior = errorcode.New(errorcode.InvalidRequest, "invalid @libre.graph.missingParentsBehavior")
// ErrPathNotFound is returned when a parent path cannot be resolved
ErrPathNotFound = errorcode.New(errorcode.ItemNotFound, "path not found")
)
type (
@@ -80,6 +101,12 @@ type (
// DrivesDriveItemProvider is the interface that needs to be implemented by the individual space service
DrivesDriveItemProvider interface {
// CreateChild creates a folder or an empty file below the given parent
CreateChild(ctx context.Context, parentID *storageprovider.ResourceId, name string, isFolder, replace bool) (*storageprovider.ResourceInfo, error)
// ResolvePath resolves relPath below parentID to its id, creating missing folders when createMissing is set
ResolvePath(ctx context.Context, parentID *storageprovider.ResourceId, relPath string, createMissing bool) (*storageprovider.ResourceId, error)
// MountShare mounts a share
MountShare(ctx context.Context, resourceID *storageprovider.ResourceId, name string) ([]*collaboration.ReceivedShare, error)
@@ -330,20 +357,139 @@ func (s DrivesDriveItemService) MountShare(ctx context.Context, resourceID *stor
return updatedShares, nil
}
// CreateChild creates a folder or empty file below parent; with replace, an existing same-named child is deleted first.
func (s DrivesDriveItemService) CreateChild(ctx context.Context, parentID *storageprovider.ResourceId, name string, isFolder, replace bool) (*storageprovider.ResourceInfo, error) {
if name == "" || name == "." || name == ".." || strings.ContainsRune(name, '/') {
return nil, ErrInvalidItemName
}
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return nil, err
}
ref := &storageprovider.Reference{
ResourceId: parentID,
Path: utils.MakeRelativePath(name),
}
create := func() error {
if isFolder {
res, err := gatewayClient.CreateContainer(ctx, &storageprovider.CreateContainerRequest{Ref: ref})
return errorcode.FromCS3Status(res.GetStatus(), err)
}
res, err := gatewayClient.TouchFile(ctx, &storageprovider.TouchFileRequest{Ref: ref})
return errorcode.FromCS3Status(res.GetStatus(), err)
}
err = create()
if replace && hasErrorCode(err, errorcode.NameAlreadyExists) {
delRes, delErr := gatewayClient.Delete(ctx, &storageprovider.DeleteRequest{Ref: ref})
if err := errorcode.FromCS3Status(delRes.GetStatus(), delErr); err != nil {
return nil, err
}
err = create()
}
if err != nil {
return nil, err
}
statRes, err := gatewayClient.Stat(ctx, &storageprovider.StatRequest{Ref: ref})
if err := errorcode.FromCS3Status(statRes.GetStatus(), err); err != nil {
return nil, err
}
// the path based stat above returns the path relative to ref, stat again by id to get the space relative path
idRes, err := gatewayClient.Stat(ctx, &storageprovider.StatRequest{
Ref: &storageprovider.Reference{ResourceId: statRes.GetInfo().GetId()},
})
if err := errorcode.FromCS3Status(idRes.GetStatus(), err); err != nil {
return nil, err
}
return idRes.GetInfo(), nil
}
// ResolvePath resolves relPath below parentID to its id; with createMissing the
// missing folders along the path are created first. It backs colon-path POST
// .../children requests and @libre.graph.missingParentsBehavior.
func (s DrivesDriveItemService) ResolvePath(ctx context.Context, parentID *storageprovider.ResourceId, relPath string, createMissing bool) (*storageprovider.ResourceId, error) {
segments := strings.Split(strings.Trim(relPath, "/"), "/")
for _, segment := range segments {
if segment == "" || segment == "." || segment == ".." {
return nil, ErrInvalidItemName
}
}
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return nil, err
}
stat := func() (*storageprovider.ResourceId, error) {
res, err := gatewayClient.Stat(ctx, &storageprovider.StatRequest{
Ref: &storageprovider.Reference{
ResourceId: parentID,
Path: utils.MakeRelativePath(relPath),
},
})
if err := errorcode.FromCS3Status(res.GetStatus(), err); err != nil {
return nil, err
}
return res.GetInfo().GetId(), nil
}
// common case: the full path exists already
id, err := stat()
switch {
case err == nil:
return id, nil
case !hasErrorCode(err, errorcode.ItemNotFound) && !hasErrorCode(err, errorcode.AccessDenied):
return nil, err
case !createMissing:
// denied collapses to not-found, matching the middleware's colon
// lookups (no existence disclosure)
return nil, ErrPathNotFound
}
var walked string
for _, segment := range segments {
walked += "/" + segment
res, err := gatewayClient.CreateContainer(ctx, &storageprovider.CreateContainerRequest{
Ref: &storageprovider.Reference{
ResourceId: parentID,
Path: utils.MakeRelativePath(walked),
},
})
// existing folders along the path (or a lost creation race) are fine, they are reused
if err := errorcode.FromCS3Status(res.GetStatus(), err); err != nil && !hasErrorCode(err, errorcode.NameAlreadyExists) {
return nil, err
}
}
return stat()
}
// hasErrorCode reports whether err is an errorcode.Error carrying the given code
func hasErrorCode(err error, code errorcode.ErrorCode) bool {
var lgErr errorcode.Error
return errors.As(err, &lgErr) && lgErr.GetCode() == code
}
// DrivesDriveItemApi is the api that registers the http endpoints which expose needed operation to the graph api.
// the business logic is delegated to the space service and further down to the cs3 client.
type DrivesDriveItemApi struct {
logger log.Logger
drivesDriveItemService DrivesDriveItemProvider
baseGraphService BaseGraphProvider
publicBaseURL *url.URL
}
// NewDrivesDriveItemApi creates a new DrivesDriveItemApi
func NewDrivesDriveItemApi(drivesDriveItemService DrivesDriveItemProvider, baseGraphService BaseGraphProvider, logger log.Logger) (DrivesDriveItemApi, error) {
func NewDrivesDriveItemApi(drivesDriveItemService DrivesDriveItemProvider, baseGraphService BaseGraphProvider, publicBaseURL *url.URL, logger log.Logger) (DrivesDriveItemApi, error) {
return DrivesDriveItemApi{
logger: log.Logger{Logger: logger.With().Str("graph api", "DrivesDriveItemApi").Logger()},
drivesDriveItemService: drivesDriveItemService,
baseGraphService: baseGraphService,
publicBaseURL: publicBaseURL,
}, nil
}
@@ -489,9 +635,8 @@ func (api DrivesDriveItemApi) UpdateDriveItem(w http.ResponseWriter, r *http.Req
render.JSON(w, r, driveItems[0])
}
// CreateDriveItem creates a drive item
// CreateDriveItem creates a driveItem at the drive root: a folder, empty file or mounted share (remoteItem).
func (api DrivesDriveItemApi) CreateDriveItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
driveID, err := parseIDParam(r, "driveID")
if err != nil {
api.logger.Debug().Err(err).Msg(ErrInvalidDriveIDOrItemID.Error())
@@ -499,18 +644,136 @@ func (api DrivesDriveItemApi) CreateDriveItem(w http.ResponseWriter, r *http.Req
return
}
if !IsShareJail(&driveID) {
api.logger.Debug().Interface("driveID", driveID).Msg(ErrNotAShareJail.Error())
ErrNotAShareJail.Render(w, r)
return
}
requestDriveItem := libregraph.DriveItem{}
if err := StrictJSONUnmarshal(r.Body, &requestDriveItem); err != nil {
api.logger.Debug().Err(err).Msg(ErrInvalidRequestBody.Error())
ErrInvalidRequestBody.Render(w, r)
return
}
if !exactlyOne(requestDriveItem.Folder != nil, requestDriveItem.File != nil, requestDriveItem.RemoteItem != nil) {
api.logger.Debug().Msg(ErrExactlyOneFacet.Error())
ErrExactlyOneFacet.Render(w, r)
return
}
if requestDriveItem.RemoteItem != nil {
api.mountShare(w, r, &driveID, requestDriveItem)
return
}
if IsShareJail(&driveID) {
api.logger.Debug().Interface("driveID", &driveID).Msg("cannot create items in the share jail")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "cannot create items in the share jail")
return
}
root := &storageprovider.ResourceId{
StorageId: driveID.GetStorageId(),
SpaceId: driveID.GetSpaceId(),
OpaqueId: driveID.GetSpaceId(),
}
api.createChild(w, r, root, requestDriveItem)
}
// CreateChildDriveItem creates a new driveItem (folder or empty file) below an existing parent item
func (api DrivesDriveItemApi) CreateChildDriveItem(w http.ResponseWriter, r *http.Request) {
_, itemID, err := GetDriveAndItemIDParam(r, &api.logger)
if err != nil {
api.logger.Debug().Err(err).Msg(ErrInvalidDriveIDOrItemID.Error())
ErrInvalidDriveIDOrItemID.Render(w, r)
return
}
requestDriveItem := libregraph.DriveItem{}
if err := StrictJSONUnmarshal(r.Body, &requestDriveItem); err != nil {
api.logger.Debug().Err(err).Msg(ErrInvalidRequestBody.Error())
ErrInvalidRequestBody.Render(w, r)
return
}
if requestDriveItem.RemoteItem != nil || !exactlyOne(requestDriveItem.Folder != nil, requestDriveItem.File != nil) {
api.logger.Debug().Msg(ErrExactlyOneChildFacet.Error())
ErrExactlyOneChildFacet.Render(w, r)
return
}
api.createChild(w, r, itemID, requestDriveItem)
}
// exactlyOne reports whether exactly one of the given flags is set
func exactlyOne(flags ...bool) bool {
set := 0
for _, flag := range flags {
if flag {
set++
}
}
return set == 1
}
// createChild creates a folder or an empty file below the given parent and renders the result
func (api DrivesDriveItemApi) createChild(w http.ResponseWriter, r *http.Request, parentID *storageprovider.ResourceId, requestDriveItem libregraph.DriveItem) {
var replace bool
switch r.URL.Query().Get("@libre.graph.conflictBehavior") {
case "", "fail":
case "replace":
replace = true
default:
api.logger.Debug().Msg(ErrInvalidConflictBehavior.Error())
ErrInvalidConflictBehavior.Render(w, r)
return
}
// colon-path POST children requests arrive unresolved: parentID is the anchor
// item, the parent path below it is resolved (or created) here, after validation
if parentPath := graphm.ParentPath(r.Context()); parentPath != "" {
var createMissing bool
switch r.URL.Query().Get("@libre.graph.missingParentsBehavior") {
case "", "fail":
case "create":
createMissing = true
default:
api.logger.Debug().Msg(ErrInvalidMissingParentsBehavior.Error())
ErrInvalidMissingParentsBehavior.Render(w, r)
return
}
var err error
parentID, err = api.drivesDriveItemService.ResolvePath(r.Context(), parentID, parentPath, createMissing)
if err != nil {
api.logger.Debug().Err(err).Msg("resolving the parent path failed")
errorcode.RenderError(w, r, err)
return
}
}
info, err := api.drivesDriveItemService.CreateChild(r.Context(), parentID, requestDriveItem.GetName(), requestDriveItem.Folder != nil, replace)
if err != nil {
api.logger.Debug().Err(err).Msg("creating drive item failed")
errorcode.RenderError(w, r, err)
return
}
driveItem, err := cs3ResourceToDriveItem(&api.logger, api.publicBaseURL, info)
if err != nil {
api.logger.Debug().Err(err).Msg(ErrDriveItemConversion.Error())
ErrDriveItemConversion.Render(w, r)
return
}
render.Status(r, http.StatusCreated)
render.JSON(w, r, driveItem)
}
// mountShare mounts a share into the share jail
func (api DrivesDriveItemApi) mountShare(w http.ResponseWriter, r *http.Request, driveID *storageprovider.ResourceId, requestDriveItem libregraph.DriveItem) {
ctx := r.Context()
if !IsShareJail(driveID) {
api.logger.Debug().Interface("driveID", driveID).Msg(ErrNotAShareJail.Error())
ErrNotAShareJail.Render(w, r)
return
}
remoteItem := requestDriveItem.GetRemoteItem()
@@ -7,8 +7,10 @@ import (
"errors"
"net/http"
"net/http/httptest"
"net/url"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
collaborationv1beta1 "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1"
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/go-chi/chi/v5"
@@ -27,6 +29,7 @@ import (
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/services/graph/mocks"
"github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode"
graphm "github.com/opencloud-eu/opencloud/services/graph/pkg/middleware"
svc "github.com/opencloud-eu/opencloud/services/graph/pkg/service/v0"
)
@@ -42,7 +45,7 @@ var _ = Describe("DrivesDriveItemService", func() {
gatewayClient = cs3mocks.NewGatewayAPIClient(GinkgoT())
gatewaySelector = mocks.NewSelectable[gateway.GatewayAPIClient](GinkgoT())
gatewaySelector.EXPECT().Next().Return(gatewayClient, nil)
gatewaySelector.EXPECT().Next().Return(gatewayClient, nil).Maybe()
service, err := svc.NewDrivesDriveItemService(logger, gatewaySelector)
Expect(err).ToNot(HaveOccurred())
@@ -365,7 +368,6 @@ var _ = Describe("DrivesDriveItemService", func() {
var _ = Describe("MountShare", func() {
It("fails if name is interpreted as absolute path", func() {
_, _ = gatewaySelector.Next() // make mockery call count happy
_, err := drivesDriveItemService.MountShare(context.Background(), nil, "/some")
Expect(err).To(MatchError(svc.ErrAbsoluteNamePath))
})
@@ -478,6 +480,216 @@ var _ = Describe("DrivesDriveItemService", func() {
Expect(shares).To(HaveLen(1))
})
})
var _ = Describe("CreateChild", func() {
parentID := &storageprovider.ResourceId{StorageId: "1", SpaceId: "2", OpaqueId: "2"}
It("rejects invalid names", func() {
for _, name := range []string{"", ".", "..", "a/b", "/a"} {
_, err := drivesDriveItemService.CreateChild(context.Background(), parentID, name, true, false)
Expect(err).To(MatchError(svc.ErrInvalidItemName))
}
})
It("creates a folder", func() {
gatewayClient.
EXPECT().
CreateContainer(mock.Anything, mock.Anything, mock.Anything).
RunAndReturn(func(ctx context.Context, request *storageprovider.CreateContainerRequest, _ ...grpc.CallOption) (*storageprovider.CreateContainerResponse, error) {
Expect(request.GetRef().GetResourceId().GetOpaqueId()).To(Equal("2"))
Expect(request.GetRef().GetPath()).To(Equal("./New Folder"))
return &storageprovider.CreateContainerResponse{Status: status.NewOK(ctx)}, nil
}).
Once()
gatewayClient.
EXPECT().
Stat(mock.Anything, mock.Anything, mock.Anything).
Return(&storageprovider.StatResponse{
Status: status.NewOK(context.Background()),
Info: &storageprovider.ResourceInfo{Id: &storageprovider.ResourceId{StorageId: "1", SpaceId: "2", OpaqueId: "3"}},
}, nil).
Times(2)
info, err := drivesDriveItemService.CreateChild(context.Background(), parentID, "New Folder", true, false)
Expect(err).ToNot(HaveOccurred())
Expect(info.GetId().GetOpaqueId()).To(Equal("3"))
})
It("creates an empty file", func() {
gatewayClient.
EXPECT().
TouchFile(mock.Anything, mock.Anything, mock.Anything).
RunAndReturn(func(ctx context.Context, request *storageprovider.TouchFileRequest, _ ...grpc.CallOption) (*storageprovider.TouchFileResponse, error) {
Expect(request.GetRef().GetPath()).To(Equal("./file.txt"))
return &storageprovider.TouchFileResponse{Status: status.NewOK(ctx)}, nil
}).
Once()
gatewayClient.
EXPECT().
Stat(mock.Anything, mock.Anything, mock.Anything).
Return(&storageprovider.StatResponse{
Status: status.NewOK(context.Background()),
Info: &storageprovider.ResourceInfo{Id: &storageprovider.ResourceId{StorageId: "1", SpaceId: "2", OpaqueId: "3"}},
}, nil).
Times(2)
_, err := drivesDriveItemService.CreateChild(context.Background(), parentID, "file.txt", false, false)
Expect(err).ToNot(HaveOccurred())
})
It("fails with nameAlreadyExists without replace", func() {
gatewayClient.
EXPECT().
TouchFile(mock.Anything, mock.Anything, mock.Anything).
Return(&storageprovider.TouchFileResponse{Status: status.NewAlreadyExists(context.Background(), nil, "exists")}, nil).
Once()
_, err := drivesDriveItemService.CreateChild(context.Background(), parentID, "file.txt", false, false)
var lgErr errorcode.Error
Expect(errors.As(err, &lgErr)).To(BeTrue())
Expect(lgErr.GetCode()).To(Equal(errorcode.NameAlreadyExists))
})
It("deletes the existing item with replace", func() {
gatewayClient.
EXPECT().
CreateContainer(mock.Anything, mock.Anything, mock.Anything).
Return(&storageprovider.CreateContainerResponse{Status: status.NewAlreadyExists(context.Background(), nil, "exists")}, nil).
Once()
gatewayClient.
EXPECT().
Delete(mock.Anything, mock.Anything, mock.Anything).
RunAndReturn(func(ctx context.Context, request *storageprovider.DeleteRequest, _ ...grpc.CallOption) (*storageprovider.DeleteResponse, error) {
Expect(request.GetRef().GetPath()).To(Equal("./New Folder"))
return &storageprovider.DeleteResponse{Status: status.NewOK(ctx)}, nil
}).
Once()
gatewayClient.
EXPECT().
CreateContainer(mock.Anything, mock.Anything, mock.Anything).
Return(&storageprovider.CreateContainerResponse{Status: status.NewOK(context.Background())}, nil).
Once()
gatewayClient.
EXPECT().
Stat(mock.Anything, mock.Anything, mock.Anything).
Return(&storageprovider.StatResponse{
Status: status.NewOK(context.Background()),
Info: &storageprovider.ResourceInfo{Id: &storageprovider.ResourceId{StorageId: "1", SpaceId: "2", OpaqueId: "3"}},
}, nil).
Times(2)
_, err := drivesDriveItemService.CreateChild(context.Background(), parentID, "New Folder", true, true)
Expect(err).ToNot(HaveOccurred())
})
})
var _ = Describe("ResolvePath", func() {
parentID := &storageprovider.ResourceId{StorageId: "1", SpaceId: "2", OpaqueId: "2"}
pathID := &storageprovider.ResourceId{StorageId: "1", SpaceId: "2", OpaqueId: "3"}
statResponse := func(code *cs3rpc.Status) *storageprovider.StatResponse {
res := &storageprovider.StatResponse{Status: code}
if code.GetCode() == cs3rpc.Code_CODE_OK {
res.Info = &storageprovider.ResourceInfo{Id: pathID}
}
return res
}
It("rejects invalid path segments", func() {
for _, relPath := range []string{"", ".", "..", "a/../b", "a//b"} {
_, err := drivesDriveItemService.ResolvePath(context.Background(), parentID, relPath, false)
Expect(err).To(MatchError(svc.ErrInvalidItemName))
}
})
It("returns the id without creating anything when the path exists", func() {
gatewayClient.
EXPECT().
Stat(mock.Anything, mock.Anything, mock.Anything).
RunAndReturn(func(ctx context.Context, request *storageprovider.StatRequest, _ ...grpc.CallOption) (*storageprovider.StatResponse, error) {
Expect(request.GetRef().GetPath()).To(Equal("./a/b"))
return statResponse(status.NewOK(ctx)), nil
}).
Once()
id, err := drivesDriveItemService.ResolvePath(context.Background(), parentID, "/a/b", true)
Expect(err).ToNot(HaveOccurred())
Expect(id.GetOpaqueId()).To(Equal("3"))
})
It("returns ErrPathNotFound for a missing path without createMissing", func() {
gatewayClient.
EXPECT().
Stat(mock.Anything, mock.Anything, mock.Anything).
Return(statResponse(status.NewNotFound(context.Background(), "missing")), nil).
Once()
_, err := drivesDriveItemService.ResolvePath(context.Background(), parentID, "/a/b", false)
Expect(err).To(MatchError(svc.ErrPathNotFound))
})
It("returns ErrPathNotFound for a denied path without createMissing", func() {
gatewayClient.
EXPECT().
Stat(mock.Anything, mock.Anything, mock.Anything).
Return(statResponse(status.NewPermissionDenied(context.Background(), nil, "denied")), nil).
Once()
_, err := drivesDriveItemService.ResolvePath(context.Background(), parentID, "/a/b", false)
Expect(err).To(MatchError(svc.ErrPathNotFound))
})
It("creates the folders along a missing path, reusing existing ones", func() {
gatewayClient.
EXPECT().
Stat(mock.Anything, mock.Anything, mock.Anything).
Return(statResponse(status.NewNotFound(context.Background(), "missing")), nil).
Once()
gatewayClient.
EXPECT().
CreateContainer(mock.Anything, mock.Anything, mock.Anything).
RunAndReturn(func(ctx context.Context, request *storageprovider.CreateContainerRequest, _ ...grpc.CallOption) (*storageprovider.CreateContainerResponse, error) {
switch request.GetRef().GetPath() {
case "./a":
// exists already, reused
return &storageprovider.CreateContainerResponse{Status: status.NewAlreadyExists(ctx, nil, "exists")}, nil
case "./a/b":
return &storageprovider.CreateContainerResponse{Status: status.NewOK(ctx)}, nil
default:
Fail("unexpected CreateContainer path " + request.GetRef().GetPath())
return nil, nil
}
}).
Times(2)
gatewayClient.
EXPECT().
Stat(mock.Anything, mock.Anything, mock.Anything).
Return(statResponse(status.NewOK(context.Background())), nil).
Once()
id, err := drivesDriveItemService.ResolvePath(context.Background(), parentID, "/a/b", true)
Expect(err).ToNot(HaveOccurred())
Expect(id.GetOpaqueId()).To(Equal("3"))
})
It("fails when folder creation is denied", func() {
gatewayClient.
EXPECT().
Stat(mock.Anything, mock.Anything, mock.Anything).
Return(statResponse(status.NewNotFound(context.Background(), "missing")), nil).
Once()
gatewayClient.
EXPECT().
CreateContainer(mock.Anything, mock.Anything, mock.Anything).
Return(&storageprovider.CreateContainerResponse{Status: status.NewPermissionDenied(context.Background(), nil, "denied")}, nil).
Once()
_, err := drivesDriveItemService.ResolvePath(context.Background(), parentID, "/a", true)
var lgErr errorcode.Error
Expect(errors.As(err, &lgErr)).To(BeTrue())
Expect(lgErr.GetCode()).To(Equal(errorcode.AccessDenied))
})
})
})
var _ = Describe("DrivesDriveItemApi", func() {
@@ -494,7 +706,9 @@ var _ = Describe("DrivesDriveItemApi", func() {
baseGraphProvider = mocks.NewBaseGraphProvider(GinkgoT())
drivesDriveItemProvider = mocks.NewDrivesDriveItemProvider(GinkgoT())
api, err := svc.NewDrivesDriveItemApi(drivesDriveItemProvider, baseGraphProvider, logger)
publicBaseURL, err := url.Parse("https://localhost:9200")
Expect(err).ToNot(HaveOccurred())
api, err := svc.NewDrivesDriveItemApi(drivesDriveItemProvider, baseGraphProvider, publicBaseURL, logger)
Expect(err).ToNot(HaveOccurred())
drivesDriveItemApi = api
@@ -1037,17 +1251,67 @@ var _ = Describe("DrivesDriveItemApi", func() {
Expect(jsonData.Get("code").String() + ": " + jsonData.Get("message").String()).To(Equal(svc.ErrInvalidDriveIDOrItemID.Error()))
})
failOnNonShareJailDriveID(drivesDriveItemApi.CreateDriveItem)
failOninvalidDriveItemBody(drivesDriveItemApi.CreateDriveItem)
It("fails on non share jail driveID when mounting a share", func() {
rCTX.URLParams.Add("driveID", "1$2")
w := httptest.NewRecorder()
driveItemJson, err := json.Marshal(libregraph.DriveItem{
RemoteItem: &libregraph.RemoteItem{
Id: conversions.ToPointer("123"),
},
})
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer(driveItemJson)).
WithContext(
context.WithValue(context.Background(), chi.RouteCtxKey, rCTX),
)
drivesDriveItemApi.CreateDriveItem(w, r)
Expect(w.Code).To(Equal(http.StatusBadRequest))
jsonData := gjson.Get(w.Body.String(), "error")
Expect(jsonData.Get("code").String() + ": " + jsonData.Get("message").String()).To(Equal(svc.ErrNotAShareJail.Error()))
})
It("fails if not exactly one facet is set", func() {
rCTX.URLParams.Add("driveID", "1$2")
for _, driveItem := range []libregraph.DriveItem{
{},
{Folder: &libregraph.Folder{}, File: &libregraph.OpenGraphFile{}},
{Folder: &libregraph.Folder{}, RemoteItem: &libregraph.RemoteItem{}},
} {
w := httptest.NewRecorder()
driveItemJson, err := json.Marshal(driveItem)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer(driveItemJson)).
WithContext(
context.WithValue(context.Background(), chi.RouteCtxKey, rCTX),
)
drivesDriveItemApi.CreateDriveItem(w, r)
Expect(w.Code).To(Equal(http.StatusBadRequest))
jsonData := gjson.Get(w.Body.String(), "error")
Expect(jsonData.Get("code").String() + ": " + jsonData.Get("message").String()).To(Equal(svc.ErrExactlyOneFacet.Error()))
}
})
It("fails on invalid request body id", func() {
rCTX.URLParams.Add("driveID", "a0ca6a90-a365-4782-871e-d44447bbc668$a0ca6a90-a365-4782-871e-d44447bbc668")
rCTX.URLParams.Add("itemID", "a0ca6a90-a365-4782-871e-d44447bbc668$a0ca6a90-a365-4782-871e-d44447bbc668!1")
w := httptest.NewRecorder()
driveItemJson, err := json.Marshal(libregraph.DriveItem{})
driveItemJson, err := json.Marshal(libregraph.DriveItem{
RemoteItem: &libregraph.RemoteItem{},
})
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer(driveItemJson)).
@@ -1179,5 +1443,271 @@ var _ = Describe("DrivesDriveItemApi", func() {
drivesDriveItemApi.CreateDriveItem(w, r)
Expect(w.Code).To(Equal(http.StatusCreated))
})
It("fails on an invalid conflictBehavior", func() {
rCTX.URLParams.Add("driveID", "1$2")
w := httptest.NewRecorder()
driveItemJson, err := json.Marshal(libregraph.DriveItem{
Name: conversions.ToPointer("New Folder"),
Folder: &libregraph.Folder{},
})
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/?%40libre.graph.conflictBehavior=rename", bytes.NewBuffer(driveItemJson)).
WithContext(
context.WithValue(context.Background(), chi.RouteCtxKey, rCTX),
)
drivesDriveItemApi.CreateDriveItem(w, r)
Expect(w.Code).To(Equal(http.StatusBadRequest))
jsonData := gjson.Get(w.Body.String(), "error")
Expect(jsonData.Get("code").String() + ": " + jsonData.Get("message").String()).To(Equal(svc.ErrInvalidConflictBehavior.Error()))
})
It("successfully creates a folder at the drive root", func() {
rCTX.URLParams.Add("driveID", "1$2")
w := httptest.NewRecorder()
driveItemJson, err := json.Marshal(libregraph.DriveItem{
Name: conversions.ToPointer("New Folder"),
Folder: &libregraph.Folder{},
})
Expect(err).ToNot(HaveOccurred())
drivesDriveItemProvider.
EXPECT().
CreateChild(mock.Anything, mock.MatchedBy(func(id *storageprovider.ResourceId) bool {
return id.GetStorageId() == "1" && id.GetSpaceId() == "2" && id.GetOpaqueId() == "2"
}), "New Folder", true, false).
Return(&storageprovider.ResourceInfo{
Id: &storageprovider.ResourceId{StorageId: "1", SpaceId: "2", OpaqueId: "3"},
Path: "./New Folder",
Type: storageprovider.ResourceType_RESOURCE_TYPE_CONTAINER,
}, nil).
Once()
r := httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer(driveItemJson)).
WithContext(
context.WithValue(context.Background(), chi.RouteCtxKey, rCTX),
)
drivesDriveItemApi.CreateDriveItem(w, r)
Expect(w.Code).To(Equal(http.StatusCreated))
Expect(gjson.Get(w.Body.String(), "name").String()).To(Equal("New Folder"))
Expect(gjson.Get(w.Body.String(), "folder").Exists()).To(BeTrue())
})
It("passes replace on conflictBehavior=replace", func() {
rCTX.URLParams.Add("driveID", "1$2")
w := httptest.NewRecorder()
driveItemJson, err := json.Marshal(libregraph.DriveItem{
Name: conversions.ToPointer("file.txt"),
File: &libregraph.OpenGraphFile{},
})
Expect(err).ToNot(HaveOccurred())
drivesDriveItemProvider.
EXPECT().
CreateChild(mock.Anything, mock.Anything, "file.txt", false, true).
Return(&storageprovider.ResourceInfo{
Id: &storageprovider.ResourceId{StorageId: "1", SpaceId: "2", OpaqueId: "3"},
Path: "./file.txt",
Type: storageprovider.ResourceType_RESOURCE_TYPE_FILE,
}, nil).
Once()
r := httptest.NewRequest(http.MethodPost, "/?%40libre.graph.conflictBehavior=replace", bytes.NewBuffer(driveItemJson)).
WithContext(
context.WithValue(context.Background(), chi.RouteCtxKey, rCTX),
)
drivesDriveItemApi.CreateDriveItem(w, r)
Expect(w.Code).To(Equal(http.StatusCreated))
})
})
Describe("CreateChildDriveItem", func() {
failOnInvalidDriveIDOrItemID(drivesDriveItemApi.CreateChildDriveItem)
failOninvalidDriveItemBody(drivesDriveItemApi.CreateChildDriveItem)
It("fails if not exactly one of folder and file is set", func() {
rCTX.URLParams.Add("driveID", "1$2")
rCTX.URLParams.Add("itemID", "1$2!3")
for _, driveItem := range []libregraph.DriveItem{
{},
{Folder: &libregraph.Folder{}, File: &libregraph.OpenGraphFile{}},
{RemoteItem: &libregraph.RemoteItem{}},
} {
w := httptest.NewRecorder()
driveItemJson, err := json.Marshal(driveItem)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer(driveItemJson)).
WithContext(
context.WithValue(context.Background(), chi.RouteCtxKey, rCTX),
)
drivesDriveItemApi.CreateChildDriveItem(w, r)
Expect(w.Code).To(Equal(http.StatusBadRequest))
jsonData := gjson.Get(w.Body.String(), "error")
Expect(jsonData.Get("code").String() + ": " + jsonData.Get("message").String()).To(Equal(svc.ErrExactlyOneChildFacet.Error()))
}
})
It("renders the service error", func() {
rCTX.URLParams.Add("driveID", "1$2")
rCTX.URLParams.Add("itemID", "1$2!3")
w := httptest.NewRecorder()
driveItemJson, err := json.Marshal(libregraph.DriveItem{
Name: conversions.ToPointer("New Folder"),
Folder: &libregraph.Folder{},
})
Expect(err).ToNot(HaveOccurred())
drivesDriveItemProvider.
EXPECT().
CreateChild(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(nil, errorcode.New(errorcode.NameAlreadyExists, "already exists")).
Once()
r := httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer(driveItemJson)).
WithContext(
context.WithValue(context.Background(), chi.RouteCtxKey, rCTX),
)
drivesDriveItemApi.CreateChildDriveItem(w, r)
Expect(w.Code).To(Equal(http.StatusConflict))
})
It("successfully creates a file below the parent", func() {
rCTX.URLParams.Add("driveID", "1$2")
rCTX.URLParams.Add("itemID", "1$2!3")
w := httptest.NewRecorder()
driveItemJson, err := json.Marshal(libregraph.DriveItem{
Name: conversions.ToPointer("file.txt"),
File: &libregraph.OpenGraphFile{},
})
Expect(err).ToNot(HaveOccurred())
drivesDriveItemProvider.
EXPECT().
CreateChild(mock.Anything, mock.MatchedBy(func(id *storageprovider.ResourceId) bool {
return id.GetStorageId() == "1" && id.GetSpaceId() == "2" && id.GetOpaqueId() == "3"
}), "file.txt", false, false).
Return(&storageprovider.ResourceInfo{
Id: &storageprovider.ResourceId{StorageId: "1", SpaceId: "2", OpaqueId: "4"},
Path: "./file.txt",
Type: storageprovider.ResourceType_RESOURCE_TYPE_FILE,
MimeType: "text/plain",
}, nil).
Once()
r := httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer(driveItemJson)).
WithContext(
context.WithValue(context.Background(), chi.RouteCtxKey, rCTX),
)
drivesDriveItemApi.CreateChildDriveItem(w, r)
Expect(w.Code).To(Equal(http.StatusCreated))
Expect(gjson.Get(w.Body.String(), "name").String()).To(Equal("file.txt"))
Expect(gjson.Get(w.Body.String(), "file.mimeType").String()).To(Equal("text/plain"))
})
It("resolves the parent path from the context first", func() {
rCTX.URLParams.Add("driveID", "1$2")
rCTX.URLParams.Add("itemID", "1$2!2")
w := httptest.NewRecorder()
driveItemJson, err := json.Marshal(libregraph.DriveItem{
Name: conversions.ToPointer("New Folder"),
Folder: &libregraph.Folder{},
})
Expect(err).ToNot(HaveOccurred())
parentID := &storageprovider.ResourceId{StorageId: "1", SpaceId: "2", OpaqueId: "3"}
drivesDriveItemProvider.
EXPECT().
ResolvePath(mock.Anything, mock.MatchedBy(func(id *storageprovider.ResourceId) bool {
return id.GetOpaqueId() == "2"
}), "/a/b", true).
Return(parentID, nil).
Once()
drivesDriveItemProvider.
EXPECT().
CreateChild(mock.Anything, parentID, "New Folder", true, false).
Return(&storageprovider.ResourceInfo{
Id: &storageprovider.ResourceId{StorageId: "1", SpaceId: "2", OpaqueId: "4"},
Path: "./New Folder",
Type: storageprovider.ResourceType_RESOURCE_TYPE_CONTAINER,
}, nil).
Once()
ctx := context.WithValue(context.Background(), chi.RouteCtxKey, rCTX)
ctx = context.WithValue(ctx, graphm.ParentPathContextKey, "/a/b")
r := httptest.NewRequest(http.MethodPost, "/?%40libre.graph.missingParentsBehavior=create", bytes.NewBuffer(driveItemJson)).WithContext(ctx)
drivesDriveItemApi.CreateChildDriveItem(w, r)
Expect(w.Code).To(Equal(http.StatusCreated))
})
It("fails on an invalid missingParentsBehavior without calling the provider", func() {
rCTX.URLParams.Add("driveID", "1$2")
rCTX.URLParams.Add("itemID", "1$2!2")
w := httptest.NewRecorder()
driveItemJson, err := json.Marshal(libregraph.DriveItem{
Name: conversions.ToPointer("New Folder"),
Folder: &libregraph.Folder{},
})
Expect(err).ToNot(HaveOccurred())
ctx := context.WithValue(context.Background(), chi.RouteCtxKey, rCTX)
ctx = context.WithValue(ctx, graphm.ParentPathContextKey, "/a/b")
r := httptest.NewRequest(http.MethodPost, "/?%40libre.graph.missingParentsBehavior=maybe", bytes.NewBuffer(driveItemJson)).WithContext(ctx)
// no ResolvePath / CreateChild expectations: the provider must not be called
drivesDriveItemApi.CreateChildDriveItem(w, r)
Expect(w.Code).To(Equal(http.StatusBadRequest))
jsonData := gjson.Get(w.Body.String(), "error")
Expect(jsonData.Get("code").String() + ": " + jsonData.Get("message").String()).To(Equal(svc.ErrInvalidMissingParentsBehavior.Error()))
})
It("does not resolve parent folders when the request is invalid", func() {
rCTX.URLParams.Add("driveID", "1$2")
rCTX.URLParams.Add("itemID", "1$2!2")
w := httptest.NewRecorder()
driveItemJson, err := json.Marshal(libregraph.DriveItem{
Name: conversions.ToPointer("New Folder"),
Folder: &libregraph.Folder{},
})
Expect(err).ToNot(HaveOccurred())
ctx := context.WithValue(context.Background(), chi.RouteCtxKey, rCTX)
ctx = context.WithValue(ctx, graphm.ParentPathContextKey, "/a/b")
r := httptest.NewRequest(http.MethodPost, "/?%40libre.graph.conflictBehavior=rename", bytes.NewBuffer(driveItemJson)).WithContext(ctx)
// no ResolvePath / CreateChild expectations: the provider must not be called
drivesDriveItemApi.CreateChildDriveItem(w, r)
Expect(w.Code).To(Equal(http.StatusBadRequest))
})
})
})
+2 -1
View File
@@ -160,7 +160,7 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx
return Graph{}, err
}
drivesDriveItemApi, err := NewDrivesDriveItemApi(drivesDriveItemService, baseGraphService, options.Logger)
drivesDriveItemApi, err := NewDrivesDriveItemApi(drivesDriveItemService, baseGraphService, publicBaseURL, options.Logger)
if err != nil {
return Graph{}, err
}
@@ -271,6 +271,7 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx
r.Get("/", drivesDriveItemApi.GetDriveItem)
r.Patch("/", drivesDriveItemApi.UpdateDriveItem)
r.Delete("/", drivesDriveItemApi.DeleteDriveItem)
r.Post("/children", drivesDriveItemApi.CreateChildDriveItem)
r.Post("/invite", driveItemPermissionsApi.Invite)
r.Post("/createLink", driveItemPermissionsApi.CreateLink)
r.Route("/permissions", func(r chi.Router) {