Skip to content

Commit 4733d41

Browse files
authored
Fixed couple of bugs in shared drives (#4703)
**Realtime not updating for the recipient** When a recipient moved a file from one folder to another within the same shared drive (both source and destination on the same owner instance), the move was implemented as a copy+delete via moveFileSameStackCore. This created a new file ID at the destination and deleted the old ID, emitting a CREATED + DELETED pair instead of a single UPDATED event. **Physical file not deleted after cross-instance move** deleteSourceFile was calling GetIndexer().DeleteFileDoc(), which only removes the CouchDB metadata record.
2 parents 4937c07 + 90cc7f4 commit 4733d41

3 files changed

Lines changed: 99 additions & 22 deletions

File tree

web/sharings/drives_test.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/cozy/cozy-stack/model/instance/lifecycle"
2222
"github.com/cozy/cozy-stack/model/job"
2323
"github.com/cozy/cozy-stack/model/sharing"
24+
"github.com/cozy/cozy-stack/model/vfs"
2425
"github.com/cozy/cozy-stack/pkg/assets/dynamic"
2526
build "github.com/cozy/cozy-stack/pkg/config"
2627
"github.com/cozy/cozy-stack/pkg/config/config"
@@ -117,12 +118,17 @@ func verifyFileMove(t *testing.T, inst *instance.Instance, fileID, expectedName,
117118
require.Equal(t, expectedContent, string(content))
118119
}
119120

120-
// verifyFileDeleted verifies that a file was deleted from the source instance.
121-
func verifyFileDeleted(t *testing.T, inst *instance.Instance, fileID string) {
121+
// verifyFileDeleted verifies that a file was fully deleted from the source instance: both the
122+
// CouchDB metadata record and the physical file content in storage. doc must be fetched before
123+
// the deletion so its path is available after the CouchDB record is gone.
124+
func verifyFileDeleted(t *testing.T, inst *instance.Instance, doc *vfs.FileDoc) {
122125
t.Helper()
123-
_, err := inst.VFS().FileByID(fileID)
126+
_, err := inst.VFS().FileByID(doc.DocID)
124127
require.Error(t, err)
125128
require.True(t, os.IsNotExist(err))
129+
130+
_, err = inst.VFS().OpenFile(doc)
131+
require.Error(t, err, "expected physical file to be deleted from storage, but it is still readable")
126132
}
127133

128134
// verifyNodeDeleted verifies that a node (file or directory) was deleted from the source instance.

web/sharings/move.go

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,21 @@ func moveDirSameStack(c echo.Context, srcInst *instance.Instance, destInst *inst
199199
return files.WrapVfsError(err)
200200
}
201201

202+
// When moving (not copying) within the same instance, use a metadata-only
203+
// update via ModifyDirMetadata.
204+
if srcInst.Domain == destInst.Domain && !copy {
205+
patch, err := buildMovePatch(srcInst.VFS(), destDirID, srcRoot.DocName, false)
206+
if err != nil {
207+
return err
208+
}
209+
newDir, err := vfs.ModifyDirMetadata(srcInst.VFS(), srcRoot, patch)
210+
if err != nil {
211+
return files.WrapVfsError(err)
212+
}
213+
obj := files.NewDir(newDir, destSharing)
214+
return jsonapi.Data(c, http.StatusCreated, obj, nil)
215+
}
216+
202217
dirs, filesToMove, err := planLocalTree(srcInst.VFS(), srcRoot, destDirID)
203218
if err != nil {
204219
return files.WrapVfsError(err)
@@ -299,6 +314,21 @@ func moveFileSameStack(c echo.Context, srcInst *instance.Instance, destInst *ins
299314
if err != nil {
300315
return files.WrapVfsError(err)
301316
}
317+
318+
// When moving (not copying) within the same instance, use a metadata-only
319+
// update via ModifyFileMetadata.
320+
if srcInst.Domain == destInst.Domain && !copy {
321+
patch, err := buildMovePatch(srcInst.VFS(), dirID, srcFile.DocName, true)
322+
if err != nil {
323+
return err
324+
}
325+
newFile, err := vfs.ModifyFileMetadata(srcInst.VFS(), srcFile, patch)
326+
if err != nil {
327+
return files.WrapVfsError(err)
328+
}
329+
obj := files.NewFile(newFile, destInst, destSharing)
330+
return jsonapi.Data(c, http.StatusCreated, obj, nil)
331+
}
302332
deleteSource := !copy
303333
newFileDoc, err := moveFileSameStackCore(srcInst, destInst, srcFile, dirID, deleteSource)
304334
if err != nil {
@@ -970,12 +1000,26 @@ func copyFileContent(destVFS vfs.VFS, newFileDoc *vfs.FileDoc, srcVFS vfs.VFS, s
9701000
}
9711001

9721002
func deleteSourceFile(srcVFS vfs.VFS, srcFile *vfs.FileDoc) error {
973-
if err := srcVFS.GetIndexer().DeleteFileDoc(srcFile); err != nil {
1003+
if err := srcVFS.DestroyFile(srcFile); err != nil {
9741004
return files.WrapVfsError(err)
9751005
}
9761006
return nil
9771007
}
9781008

1009+
// buildMovePatch creates a DocPatch for a metadata-only move to destDirID,
1010+
// resolving name conflicts automatically. isFile controls the conflict naming strategy.
1011+
func buildMovePatch(fs vfs.VFS, destDirID, currentName string, isFile bool) (*vfs.DocPatch, error) {
1012+
uniqueName, err := ensureUniqueName(fs, destDirID, currentName, isFile)
1013+
if err != nil {
1014+
return nil, files.WrapVfsError(err)
1015+
}
1016+
patch := &vfs.DocPatch{DirID: &destDirID}
1017+
if uniqueName != currentName {
1018+
patch.Name = &uniqueName
1019+
}
1020+
return patch, nil
1021+
}
1022+
9791023
// ensureUniqueName ensures a child name under parentDirID is unique by checking the
9801024
// indexer and applying vfs.ConflictName when needed. isFile controls the conflict
9811025
// naming strategy on files vs directories.

web/sharings/move_test.go

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import (
77
"testing"
88

99
"github.com/cozy/cozy-stack/model/instance"
10-
1110
"github.com/cozy/cozy-stack/model/instance/lifecycle"
1211
"github.com/cozy/cozy-stack/pkg/assets/dynamic"
1312
build "github.com/cozy/cozy-stack/pkg/config"
@@ -206,6 +205,11 @@ func TestSharedDrivesMove(t *testing.T) {
206205
_, eB, _ := env.createClients(t)
207206
// Perform the move operation
208207
fileToMoveSameStack := createFile(t, eB, "", "file-to-upload.txt", env.bettyToken)
208+
209+
// Pre-fetch the source FileDoc before the move so we can verify physical deletion later
210+
srcFileDoc, err := env.betty.VFS().FileByID(fileToMoveSameStack)
211+
require.NoError(t, err)
212+
209213
responseObj := postMove(t, eB, env.bettyToken, `{
210214
"source": {
211215
"file_id": "`+fileToMoveSameStack+`"
@@ -223,8 +227,8 @@ func TestSharedDrivesMove(t *testing.T) {
223227
// Verify the file was moved and content preserved
224228
verifyFileMove(t, env.acme, movedFileID, "file-to-upload.txt", env.productDirID, "foo")
225229

226-
// Verify the original file was deleted
227-
verifyFileDeleted(t, env.betty, fileToMoveSameStack)
230+
// Verify the original file was deleted (both metadata and physical content)
231+
verifyFileDeleted(t, env.betty, srcFileDoc)
228232
})
229233

230234
// Force the cross-stack path even if instances are on the same server
@@ -233,6 +237,10 @@ func TestSharedDrivesMove(t *testing.T) {
233237
fileToMoveDifferentStack := createFile(t, eB, "", "file-to-upload-diff.txt", env.bettyToken)
234238
destDirInSharedDrive := createDirectory(t, eA, env.productDirID, "Dest DIR To Move", env.acmeToken)
235239

240+
// Pre-fetch the source FileDoc before the move so we can verify physical deletion later
241+
srcFileDoc, err := env.betty.VFS().FileByID(fileToMoveDifferentStack)
242+
require.NoError(t, err)
243+
236244
cleanup := forceCrossStack(t, env.tsA.URL)
237245
defer cleanup()
238246

@@ -253,8 +261,8 @@ func TestSharedDrivesMove(t *testing.T) {
253261
// Verify the file was moved and content preserved
254262
verifyFileMove(t, env.acme, movedFileID, "file-to-upload-diff.txt", destDirInSharedDrive, "foo")
255263

256-
// Verify the original file was deleted
257-
verifyFileDeleted(t, env.acme, fileToMoveDifferentStack)
264+
// Verify the original file was deleted from betty (both metadata and physical content)
265+
verifyFileDeleted(t, env.betty, srcFileDoc)
258266
})
259267

260268
t.Run("SuccessfulMove_FromSharedDrive_SameStack", func(t *testing.T) {
@@ -264,6 +272,10 @@ func TestSharedDrivesMove(t *testing.T) {
264272
// Create destination directory on the target instance
265273
destDirID := createRootDirectory(t, eB, "Destination Dir", env.bettyToken)
266274

275+
// Pre-fetch the source FileDoc before the move so we can verify physical deletion later
276+
srcFileDoc, err := env.acme.VFS().FileByID(fileToMove)
277+
require.NoError(t, err)
278+
267279
responseObj := postMove(t, eB, env.bettyToken, `{
268280
"source": {
269281
"instance": "https://`+env.acme.Domain+`",
@@ -281,8 +293,8 @@ func TestSharedDrivesMove(t *testing.T) {
281293
// Verify the file was moved to the destination
282294
verifyFileMove(t, env.betty, movedFileID, "file-to-move-upstream.txt", destDirID, "foo")
283295

284-
// Verify the original file was deleted from source
285-
verifyFileDeleted(t, env.acme, fileToMove)
296+
// Verify the original file was deleted (both metadata and physical content)
297+
verifyFileDeleted(t, env.acme, srcFileDoc)
286298
})
287299

288300
// Force the cross-stack path even if instances are on the same server
@@ -293,6 +305,10 @@ func TestSharedDrivesMove(t *testing.T) {
293305
// Create destination directory on the target (owner) instance
294306
destDirID := createRootDirectory(t, eB, "Destination Dir Diff", env.bettyToken)
295307

308+
// Pre-fetch the source FileDoc before the move so we can verify physical deletion later
309+
srcFileDoc, err := env.acme.VFS().FileByID(fileToDiffStack)
310+
require.NoError(t, err)
311+
296312
cleanup := forceCrossStack(t, env.tsA.URL)
297313
defer cleanup()
298314

@@ -313,8 +329,8 @@ func TestSharedDrivesMove(t *testing.T) {
313329
// Verify the file was moved to the destination
314330
verifyFileMove(t, env.betty, movedFileID, fileName, destDirID, "foo")
315331

316-
// Verify the original file was deleted from source
317-
verifyFileDeleted(t, env.acme, fileToDiffStack)
332+
// Verify the original file was deleted from source (both metadata and physical content)
333+
verifyFileDeleted(t, env.acme, srcFileDoc)
318334
})
319335

320336
t.Run("SuccessfulMove_BetweenSharedDrives_DifferentStack", func(t *testing.T) {
@@ -328,6 +344,10 @@ func TestSharedDrivesMove(t *testing.T) {
328344
fileName := "file-to-move-between-shared-drives.txt"
329345
toMove := createFile(t, eA, secondRootDirID, fileName, env.acmeToken)
330346

347+
// Pre-fetch the source FileDoc before the move so we can verify physical deletion later
348+
srcFileDoc, err := env.acme.VFS().FileByID(toMove)
349+
require.NoError(t, err)
350+
331351
cleanup := forceCrossStack(t, env.tsA.URL)
332352
defer cleanup()
333353

@@ -349,7 +369,8 @@ func TestSharedDrivesMove(t *testing.T) {
349369

350370
// Verify the file was moved to the destination
351371
verifyFileMove(t, env.acme, movedFileID, fileName, env.meetingsDirID, "foo")
352-
verifyFileDeleted(t, env.acme, toMove)
372+
// Verify the original file was deleted (both metadata and physical content)
373+
verifyFileDeleted(t, env.acme, srcFileDoc)
353374
})
354375

355376
t.Run("SuccessfulMoveBetween_SharedDrives_SameStack", func(t *testing.T) {
@@ -392,11 +413,10 @@ func TestSharedDrivesMove(t *testing.T) {
392413
// Verify the response contains the new file
393414
movedFileID := assertMoveResponseWithSharing(t, responseObj, fileName, meetingsID, firstSharingID)
394415

395-
// Verify the file was moved to the destination
416+
// Verify the file was moved to the destination.
417+
// Same-instance move preserves the file ID (metadata-only update via ModifyFileMetadata).
418+
require.Equal(t, toMove, movedFileID, "same-instance move must preserve the file ID")
396419
verifyFileMove(t, env.acme, movedFileID, fileName, meetingsID, "foo")
397-
398-
// Verify the original file was deleted from source
399-
verifyFileDeleted(t, env.acme, toMove)
400420
})
401421

402422
// File move with conflict resolution: moving a file between shared drives (same stack)
@@ -432,12 +452,15 @@ func TestSharedDrivesMove(t *testing.T) {
432452

433453
// Verify the file was moved with a renamed name (conflict resolution)
434454
movedFileName := assertAutoRename(t, responseObj, conflictName)
455+
movedFileID := responseObj.Path("$.data.id").String().Raw()
435456

436457
// Verify both files exist in destination (original + renamed)
437458
verifyBothFilesExist(t, env.acme, env.meetingsDirID, conflictName, movedFileName)
438459

439-
// Verify source file was deleted
440-
verifyFileDeleted(t, env.acme, sourceFileID)
460+
// Same-instance move preserves the file ID (metadata-only update via ModifyFileMetadata).
461+
// The source file was moved (not deleted): verify it now lives at the destination with the renamed name.
462+
require.Equal(t, sourceFileID, movedFileID, "same-instance move must preserve the file ID")
463+
verifyFileExists(t, env.acme, sourceFileID, movedFileName, env.meetingsDirID, "foo")
441464
})
442465

443466
// File move with conflict resolution: moving a file between shared drives (same stack)
@@ -459,6 +482,10 @@ func TestSharedDrivesMove(t *testing.T) {
459482
// Create source file with the same name in the second shared drive (source)
460483
sourceFileID := createFile(t, eA, secondRootDirID, conflictName, env.acmeToken)
461484

485+
// Pre-fetch the source FileDoc before the move so we can verify physical deletion later
486+
srcFileDoc, err := env.acme.VFS().FileByID(sourceFileID)
487+
require.NoError(t, err)
488+
462489
// Attempt to move → should succeed with auto-renaming
463490
responseObj := postMove(t, eB, env.bettyToken, `{
464491
"source": {
@@ -479,8 +506,8 @@ func TestSharedDrivesMove(t *testing.T) {
479506
// Verify both files exist in destination (original + renamed)
480507
verifyBothFilesExist(t, env.acme, env.meetingsDirID, conflictName, movedFileName)
481508

482-
// Verify source file was deleted
483-
verifyFileDeleted(t, env.acme, sourceFileID)
509+
// Verify source file was deleted (both metadata and physical content)
510+
verifyFileDeleted(t, env.acme, srcFileDoc)
484511
})
485512

486513
// Folder move with conflict resolution: moving a directory between shared drives (same stack)

0 commit comments

Comments
 (0)