diff --git a/apps/server/AliasVault.Client/Main/Models/ItemEdit.cs b/apps/server/AliasVault.Client/Main/Models/ItemEdit.cs index 6f5b252d7..e3fe7575a 100644 --- a/apps/server/AliasVault.Client/Main/Models/ItemEdit.cs +++ b/apps/server/AliasVault.Client/Main/Models/ItemEdit.cs @@ -86,6 +86,7 @@ public sealed class ItemEdit /// /// Creates an ItemEdit instance from an Item entity. + /// Creates clones of Attachments, TotpCodes, and Passkeys to avoid modifying EF-tracked entities. /// /// The item entity to convert. /// A new ItemEdit instance. @@ -99,9 +100,9 @@ public sealed class ItemEdit LogoId = item.LogoId, ServiceLogo = item.Logo?.FileData, FolderId = item.FolderId, - Attachments = item.Attachments.Where(a => !a.IsDeleted).ToList(), - TotpCodes = item.TotpCodes.Where(t => !t.IsDeleted).ToList(), - Passkeys = item.Passkeys.Where(p => !p.IsDeleted).ToList(), + Attachments = item.Attachments.Where(a => !a.IsDeleted).Select(CloneAttachment).ToList(), + TotpCodes = item.TotpCodes.Where(t => !t.IsDeleted).Select(CloneTotpCode).ToList(), + Passkeys = item.Passkeys.Where(p => !p.IsDeleted).Select(ClonePasskey).ToList(), CreateDate = item.CreatedAt, LastUpdate = item.UpdatedAt, }; @@ -569,4 +570,66 @@ public sealed class ItemEdit // not yet in the registry - treat as custom for now return FieldCategory.Custom; } + + /// + /// Creates a clone of an Attachment entity to avoid modifying EF-tracked entities. + /// + /// The attachment to clone. + /// A new Attachment instance with copied values. + private static Attachment CloneAttachment(Attachment attachment) + { + return new Attachment + { + Id = attachment.Id, + Filename = attachment.Filename, + Blob = attachment.Blob, + ItemId = attachment.ItemId, + CreatedAt = attachment.CreatedAt, + UpdatedAt = attachment.UpdatedAt, + IsDeleted = attachment.IsDeleted, + }; + } + + /// + /// Creates a clone of a TotpCode entity to avoid modifying EF-tracked entities. + /// + /// The TOTP code to clone. + /// A new TotpCode instance with copied values. + private static TotpCode CloneTotpCode(TotpCode totpCode) + { + return new TotpCode + { + Id = totpCode.Id, + Name = totpCode.Name, + SecretKey = totpCode.SecretKey, + ItemId = totpCode.ItemId, + CreatedAt = totpCode.CreatedAt, + UpdatedAt = totpCode.UpdatedAt, + IsDeleted = totpCode.IsDeleted, + }; + } + + /// + /// Creates a clone of a Passkey entity to avoid modifying EF-tracked entities. + /// + /// The passkey to clone. + /// A new Passkey instance with copied values. + private static Passkey ClonePasskey(Passkey passkey) + { + return new Passkey + { + Id = passkey.Id, + RpId = passkey.RpId, + UserHandle = passkey.UserHandle, + PublicKey = passkey.PublicKey, + PrivateKey = passkey.PrivateKey, + PrfKey = passkey.PrfKey, + DisplayName = passkey.DisplayName, + AdditionalData = passkey.AdditionalData, + ItemId = passkey.ItemId, + CreatedAt = passkey.CreatedAt, + UpdatedAt = passkey.UpdatedAt, + IsDeleted = passkey.IsDeleted, + }; + } } diff --git a/apps/server/Tests/AliasVault.E2ETests/Tests/Client/Shard5/AttachmentTests.cs b/apps/server/Tests/AliasVault.E2ETests/Tests/Client/Shard5/AttachmentTests.cs index 33801d269..e20b85971 100644 --- a/apps/server/Tests/AliasVault.E2ETests/Tests/Client/Shard5/AttachmentTests.cs +++ b/apps/server/Tests/AliasVault.E2ETests/Tests/Client/Shard5/AttachmentTests.cs @@ -439,4 +439,90 @@ public class AttachmentTests : ClientPlaywrightTest File.Delete(downloadedFilePath); } } + + /// + /// Test that deleting an attachment and then canceling the edit does not persist the deletion. + /// This verifies that attachment deletions only happen in-memory until the save button is pressed. + /// + /// Async task. + [Test] + [Order(7)] + public async Task DeleteAttachmentCancelDoesNotPersist() + { + // Create a new alias with an attachment. + var serviceName = "Test Service Delete Cancel"; + await CreateItemEntry( + new Dictionary + { + { "service-name", serviceName }, + }, + async () => + { + // Add the attachments section via the + menu + await AddFieldSectionAsync("Attachments"); + + // Wait for the file input to appear + await Page.WaitForSelectorAsync("input[type='file']"); + + // Upload file. + var fileInput = Page.Locator("input[type='file']"); + var fileContent = await ResourceReaderUtility.ReadEmbeddedResourceBytesAsync("AliasVault.E2ETests.TestData.TestAttachment.txt"); + + // Create a temporary file with the content and original file name + var originalFileName = "TestAttachmentCancel.txt"; + var tempFilePath = Path.Combine(Path.GetTempPath(), originalFileName); + await File.WriteAllBytesAsync(tempFilePath, fileContent); + + // Set the file input using the temporary file + await fileInput.SetInputFilesAsync(tempFilePath); + + // Wait for the file to be uploaded + await Page.WaitForSelectorAsync("text=TestAttachmentCancel.txt"); + + // Delete the temporary file + File.Delete(tempFilePath); + }); + + // Check that the attachment name appears on the view page. + var pageContent = await Page.TextContentAsync("body"); + Assert.That(pageContent, Does.Contain("TestAttachmentCancel.txt"), "Uploaded attachment name does not appear on view page."); + + // Click the edit button + await Page.ClickAsync("text=Edit"); + await WaitForUrlAsync("items/**/edit", "Edit the existing item"); + + // Verify attachment appears on edit page + pageContent = await Page.TextContentAsync("body"); + Assert.That(pageContent, Does.Contain("TestAttachmentCancel.txt"), "Attachment name does not appear on edit page."); + + // Find and click the delete button for the attachment + var deleteButton = Page.Locator("button:has-text('Delete')").First; + await deleteButton.ClickAsync(); + + // Wait a moment for the UI to update + await Task.Delay(200); + + // Check that the attachment name no longer appears on the edit page + pageContent = await Page.TextContentAsync("body"); + Assert.That(pageContent, Does.Not.Contain("TestAttachmentCancel.txt"), "Deleted attachment name still appears on edit page after deletion."); + + // Click the cancel button instead of save + var cancelButton = Page.Locator("text=Cancel").First; + await cancelButton.ClickAsync(); + + // Wait for the view page to load + await WaitForUrlAsync("items/**", "View item"); + + // Check that the attachment name STILL appears on the view page (deletion was not persisted) + pageContent = await Page.TextContentAsync("body"); + Assert.That(pageContent, Does.Contain("TestAttachmentCancel.txt"), "Attachment was deleted despite canceling the edit. Deletions should only persist on save."); + + // Edit the item again to double-check the attachment is still there + await Page.ClickAsync("text=Edit"); + await WaitForUrlAsync("items/**/edit", "Edit the existing item"); + + // Verify attachment still appears on edit page + pageContent = await Page.TextContentAsync("body"); + Assert.That(pageContent, Does.Contain("TestAttachmentCancel.txt"), "Attachment name does not appear on edit page after canceling deletion. Deletion was incorrectly persisted."); + } }