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.");
+ }
}