[PM-41472] feat: add bulk folder delete endpoint - #8157
Conversation
Add DELETE /folders for deleting multiple personal folders in one request, with a Folder_DeleteByIds stored procedure and matching EF implementation. Also fixes EF single-folder delete, which left ciphers pointing at the deleted folder and never bumped the account revision date.
| [UserId] = @UserId | ||
| AND [Status] = 2 -- Confirmed | ||
| ) | ||
| UPDATE |
There was a problem hiding this comment.
Since there's no explicit transaction here and none in the C# caller, are there any concerns if these 3 data modification statements do not all complete atomically?
There was a problem hiding this comment.
Nice catch. It wasn't atomic as written, although it was fail safe
Fixed 55067ab
🤖 Bitwarden Claude Code ReviewOverall Assessment: APPROVE Re-reviewed after Also re-verified the rest of the stack: Code Review DetailsNo outstanding findings. |
| var userCipherDetails = new UserCipherDetailsQuery(userId).Run(dbContext); | ||
| var filedCiphers = from ucd in userCipherDetails | ||
| join c in dbContext.Ciphers.Where(c => c.Folders != null) | ||
| on ucd.Id equals c.Id | ||
| select c; | ||
|
|
||
| await filedCiphers.ForEachAsync(cipher => |
There was a problem hiding this comment.
Details and fix
UserCipherDetailsQuery returns the user's personal ciphers plus every org cipher they can reach through a collection. The only server-side narrowing here is c.Folders != null, which is true for any cipher that any member has filed. So for a user in an org with 50k shared items, deleting a single folder streams and change-tracks ~50k Cipher rows (including the Data blob) into the DbContext. ForEachAsync streams, but tracked entities accumulate for the lifetime of the context, so peak memory scales with the accessible vault, not with the folders being deleted.
This also now applies to the pre-existing single-folder path, since DeleteAsync was overridden to delegate here.
A server-side filter on the user's key in the Folders map narrows this to only ciphers this user has filed, and translates to a LIKE on all three EF providers:
var userKey = userId.ToString();
var filedCiphers = from ucd in userCipherDetails
join c in dbContext.Ciphers.Where(c => c.Folders != null && c.Folders.Contains(userKey))
on ucd.Id equals c.Id
select c;The same Folders.Contains(userId.ToString()) guard is already used in CipherRepository (src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs:617), so the JSON key format is consistent.
|
|
||
| await folderRepository.DeleteManyAsync([ownFolder.Id, otherUsersFolder.Id], user.Id); | ||
|
|
||
| Assert.Null(await folderRepository.GetByIdAsync(ownFolder.Id, user.Id)); |
There was a problem hiding this comment.
Details and fix
otherUsersFolder and the other user's cipher are created and passed into DeleteManyAsync, but the only assertion is that ownFolder was deleted. The test passes today and would keep passing if the AND [UserId] = @UserId filter in Folder_DeleteByIds (or the f.UserId == userId predicate in the EF path) were dropped — which is exactly the cross-user data-deletion regression this test exists to catch.
Assert.Null(await folderRepository.GetByIdAsync(ownFolder.Id, user.Id));
Assert.NotNull(await folderRepository.GetByIdAsync(otherUsersFolder.Id, otherUser.Id));Asserting the other user's cipher is still filed under otherUsersFolder.Id would also cover the JSON_MODIFY scoping.
|
|
||
| await folderRepository.DeleteManyAsync([deletedFolder.Id], user.Id); | ||
|
|
||
| Assert.Null(await folderRepository.GetByIdAsync(deletedFolder.Id, user.Id)); |
There was a problem hiding this comment.
♻️ DEBT: Tests named for cipher unfiling create ciphers but never assert on them.
Details and fix
DeleteManyAsync_DeletesRequestedFolders_AndUnfilesTheirCiphers creates cipherInDeletedFolder, cipherInKeptFolder, unfiledCipher, and keptFolder, then asserts only that deletedFolder is gone. DeleteAsync_UnfilesTheCiphersInTheDeletedFolder (line 107) has the same shape.
The unfiling behavior is the newly added part of the EF path, and it is only covered by DeleteManyAsync_DeletesEveryRequestedFolder. Adding the assertions these tests already have the fixtures for closes the gap:
Assert.NotNull(await folderRepository.GetByIdAsync(keptFolder.Id, user.Id));
Assert.Null((await cipherRepository.GetByIdAsync(cipherInDeletedFolder.Id, user.Id)).FolderId);
Assert.Equal(keptFolder.Id, (await cipherRepository.GetByIdAsync(cipherInKeptFolder.Id, user.Id)).FolderId);
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #8157 +/- ##
==========================================
+ Coverage 63.04% 67.65% +4.61%
==========================================
Files 2315 2322 +7
Lines 100510 100894 +384
Branches 9043 9085 +42
==========================================
+ Hits 63364 68258 +4894
+ Misses 34957 30346 -4611
- Partials 2189 2290 +101 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
🎟️ Tracking
PM-41472
📔 Objective
VFO1 introduces a new My Folders page in the web client, where users can multiselect folders and delete them in bulk. There is no bulk folder delete anywhere in the stack today, so the client has to issue one
DELETE /folders/{id}per selected folder — N round trips for a single user action, and non-atomic.This adds
DELETE /folders, which takes a list of folder ids and deletes them in one request.