Fix attachments/totpcodes/passkey mutations in web app to not be committed immediately (#1595)

This commit is contained in:
Leendert de Borst
2026-02-02 20:42:40 +01:00
committed by Leendert de Borst
parent 9a3189048e
commit f878afa6be
2 changed files with 152 additions and 3 deletions

View File

@@ -86,6 +86,7 @@ public sealed class ItemEdit
/// <summary>
/// Creates an ItemEdit instance from an Item entity.
/// Creates clones of Attachments, TotpCodes, and Passkeys to avoid modifying EF-tracked entities.
/// </summary>
/// <param name="item">The item entity to convert.</param>
/// <returns>A new ItemEdit instance.</returns>
@@ -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;
}
/// <summary>
/// Creates a clone of an Attachment entity to avoid modifying EF-tracked entities.
/// </summary>
/// <param name="attachment">The attachment to clone.</param>
/// <returns>A new Attachment instance with copied values.</returns>
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,
};
}
/// <summary>
/// Creates a clone of a TotpCode entity to avoid modifying EF-tracked entities.
/// </summary>
/// <param name="totpCode">The TOTP code to clone.</param>
/// <returns>A new TotpCode instance with copied values.</returns>
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,
};
}
/// <summary>
/// Creates a clone of a Passkey entity to avoid modifying EF-tracked entities.
/// </summary>
/// <param name="passkey">The passkey to clone.</param>
/// <returns>A new Passkey instance with copied values.</returns>
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,
};
}
}

View File

@@ -439,4 +439,90 @@ public class AttachmentTests : ClientPlaywrightTest
File.Delete(downloadedFilePath);
}
}
/// <summary>
/// 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.
/// </summary>
/// <returns>Async task.</returns>
[Test]
[Order(7)]
public async Task DeleteAttachmentCancelDoesNotPersist()
{
// Create a new alias with an attachment.
var serviceName = "Test Service Delete Cancel";
await CreateItemEntry(
new Dictionary<string, string>
{
{ "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.");
}
}