Skip to content

[PM-41472] feat: add bulk folder delete endpoint - #8157

Open
gbubemismith wants to merge 7 commits into
mainfrom
vault/pm-41472/add-bulk-folder-delete-endpoint
Open

[PM-41472] feat: add bulk folder delete endpoint#8157
gbubemismith wants to merge 7 commits into
mainfrom
vault/pm-41472/add-bulk-folder-delete-endpoint

Conversation

@gbubemismith

@gbubemismith gbubemismith commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

🎟️ 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.

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.
@gbubemismith gbubemismith added ai-review-vnext Request a Claude code review using the vNext workflow t:feature Change Type - Feature Development labels Aug 6, 2026
[UserId] = @UserId
AND [Status] = 2 -- Confirmed
)
UPDATE

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch. It wasn't atomic as written, although it was fail safe
Fixed 55067ab

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

🤖 Bitwarden Claude Code Review

Overall Assessment: APPROVE

Re-reviewed after 26fcd5f8e, which addresses all three findings from the previous pass. The EF DeleteManyAsync cipher scan is now narrowed with c.Folders.Contains(userKey) — matching the existing convention at src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs:617 — and the redundant dbContext.Attach(cipher) was dropped since the join projects the tracked Cipher entity directly. The integration tests now assert the behavior they are named for: DeleteManyAsync_IgnoresFoldersBelongingToAnotherUser checks the other user's folder and cipher survive, and both unfiling tests assert FolderId is null afterward while the kept folder's cipher is untouched.

Also re-verified the rest of the stack: IFolderRepository.GetByIdAsync is now correctly Task<Folder?> (both implementations already returned null on a userId mismatch); ownership is enforced independently in the command, the EF f.UserId == userId predicate, and the @OwnedIds filter in Folder_DeleteByIds; the EF path stages the folder removal, cipher Folders updates, and UserBumpAccountRevisionDateAsync into a single SaveChangesAsync, matching the Dapper SqlTransaction wrapper; and the migration 2026-08-06_01_AddFolderDeleteByIds.sql passes the naming/order check. No new findings.

Code Review Details

No outstanding findings.

Comment on lines +76 to +82
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 =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ IMPORTANT: This materializes and tracks every cipher the user can access, not just the ones filed under the deleted folders.

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));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ IMPORTANT: This test never asserts the property it is named for — the other user's folder is never checked.

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));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ 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

codecov Bot commented Aug 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.02151% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.65%. Comparing base (4f8c0f0) to head (26fcd5f).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
...tyFramework/Vault/Repositories/FolderRepository.cs 84.78% 5 Missing and 2 partials ⚠️
...ture.Dapper/Vault/Repositories/FolderRepository.cs 76.47% 4 Missing ⚠️
...rc/Core/Vault/Commands/DeleteManyFoldersCommand.cs 87.50% 0 Missing and 2 partials ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

mkincaid-bw
mkincaid-bw previously approved these changes Aug 7, 2026

@mkincaid-bw mkincaid-bw left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review-vnext Request a Claude code review using the vNext workflow t:feature Change Type - Feature Development

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants