Add attachment delete test and fix bug with IsDeleted flag (#287)

This commit is contained in:
Leendert de Borst
2024-10-13 18:29:36 +02:00
parent 6d795c6370
commit dcf04f040d
8 changed files with 161 additions and 79 deletions

View File

@@ -1,4 +1,5 @@
@using System.IO
@inject ILogger<AttachmentUploader> Logger
<div class="col-span-6 sm:col-span-3">
<InputFile OnChange="@HandleFileSelection" class="block w-full text-sm text-gray-900 border border-gray-300 rounded-lg cursor-pointer bg-gray-50 dark:text-gray-400 focus:outline-none dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400" />
@@ -6,12 +7,12 @@
{
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">@StatusMessage</p>
}
@if (Attachments.Any())
@if (Attachments.Exists(x => !x.IsDeleted))
{
<div class="mt-4">
<h4 class="mb-2 text-lg font-semibold dark:text-white">Attachments:</h4>
<ul class="list-disc list-inside">
@foreach (var attachment in Attachments)
@foreach (var attachment in Attachments.Where(x => !x.IsDeleted))
{
<li class="flex items-center justify-between text-sm text-gray-600 dark:text-gray-400">
<span>@attachment.Filename</span>
@@ -42,9 +43,12 @@
/// Original attachments that were passed in. This is used to determine if a deleted attachment was part of the original set and
/// can be hard deleted (did not exist in the original set) or should be soft deleted (was part of the original set).
/// </summary>
private List<Guid> OriginalAttachmentsIds = [];
private List<Guid> OriginalAttachmentsIds { get; set; } = [];
private string StatusMessage = string.Empty;
/// <summary>
/// Status message to display.
/// </summary>
private string StatusMessage { get; set; } = string.Empty;
/// <inheritdoc />
protected override void OnInitialized()
@@ -81,7 +85,7 @@
catch (Exception ex)
{
StatusMessage = $"Error uploading file: {ex.Message}";
Console.Error.WriteLine("Error uploading file: {0}", ex.Message);
Logger.LogError(ex, "Error uploading file.");
}
}

View File

@@ -2,7 +2,7 @@
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-4 text-xl font-semibold dark:text-white">Attachments</h3>
@if (Attachments.Any())
@if (Attachments.Any(x => !x.IsDeleted))
{
<div class="overflow-x-auto">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
@@ -13,7 +13,7 @@
</tr>
</thead>
<tbody>
@foreach (var attachment in Attachments)
@foreach (var attachment in Attachments.Where(x => !x.IsDeleted))
{
<tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700">
<td class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">

View File

@@ -2,10 +2,10 @@
@inject JsInteropService JsInteropService
@implements IDisposable
<label for="@_inputId" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">@Label</label>
<label for="@Id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">@Label</label>
<div class="relative">
<input type="text" id="@_inputId" class="outline-0 shadow-sm bg-gray-50 border @(_copied ? "border-green-500 border-2" : "border-gray-300") text-gray-900 sm:text-sm rounded-lg block w-full p-2.5 pr-10 dark:bg-gray-700 dark:border-@(_copied ? "green-500" : "gray-600") dark:placeholder-gray-400 dark:text-white" value="@Value" @onclick="CopyToClipboard" readonly>
@if (_copied)
<input type="text" id="@Id" class="outline-0 shadow-sm bg-gray-50 border @(Copied ? "border-green-500 border-2" : "border-gray-300") text-gray-900 sm:text-sm rounded-lg block w-full p-2.5 pr-10 dark:bg-gray-700 dark:border-@(Copied ? "green-500" : "gray-600") dark:placeholder-gray-400 dark:text-white" value="@Value" @onclick="CopyToClipboard" readonly>
@if (Copied)
{
<span class="absolute inset-y-0 right-0 flex items-center pr-3 text-green-500 dark:text-green-400">
Copied!
@@ -14,6 +14,12 @@
</div>
@code {
/// <summary>
/// Id for the input field. Defaults to a random GUID if not provided.
/// </summary>
[Parameter]
public string Id { get; set; } = Guid.NewGuid().ToString();
/// <summary>
/// The label for the input.
/// </summary>
@@ -26,8 +32,7 @@
[Parameter]
public string Value { get; set; } = string.Empty;
private bool _copied => ClipboardCopyService.GetCopiedId() == _inputId;
private readonly string _inputId = Guid.NewGuid().ToString();
private bool Copied => ClipboardCopyService.GetCopiedId() == Id;
/// <inheritdoc />
protected override void OnInitialized()
@@ -38,11 +43,11 @@
private async Task CopyToClipboard()
{
await JsInteropService.CopyToClipboard(Value);
ClipboardCopyService.SetCopied(_inputId);
ClipboardCopyService.SetCopied(Id);
// After 2 seconds, reset the copied state if it's still the same element
await Task.Delay(2000);
if (ClipboardCopyService.GetCopiedId() == _inputId)
if (ClipboardCopyService.GetCopiedId() == Id)
{
ClipboardCopyService.SetCopied(string.Empty);
}

View File

@@ -2,12 +2,12 @@
@inject JsInteropService JsInteropService
@implements IDisposable
<label for="@_inputId" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">@Label</label>
<label for="@Id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">@Label</label>
<div class="relative">
<input type="@(_isPasswordVisible ? "text" : "password")" id="@_inputId" class="outline-0 shadow-sm bg-gray-50 border @(_copied ? "border-green-500 border-2" : "border-gray-300") text-gray-900 sm:text-sm rounded-lg block w-full p-2.5 pr-20 dark:bg-gray-700 dark:border-@(_copied ? "green-500" : "gray-600") dark:placeholder-gray-400 dark:text-white" value="@Value" @onclick="CopyToClipboard" readonly>
<input type="@(IsPasswordVisible ? "text" : "password")" id="@Id" class="outline-0 shadow-sm bg-gray-50 border @(Copied ? "border-green-500 border-2" : "border-gray-300") text-gray-900 sm:text-sm rounded-lg block w-full p-2.5 pr-20 dark:bg-gray-700 dark:border-@(Copied ? "green-500" : "gray-600") dark:placeholder-gray-400 dark:text-white" value="@Value" @onclick="CopyToClipboard" readonly>
<button type="button" class="absolute inset-y-1 right-1 flex items-center justify-center w-10 h-8 text-gray-500 bg-gray-200 rounded-md shadow-sm hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-colors duration-200 dark:bg-gray-600 dark:hover:bg-gray-500 dark:text-gray-300" @onclick="TogglePasswordVisibility">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
@if (_isPasswordVisible)
@if (IsPasswordVisible)
{
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
@@ -18,7 +18,7 @@
}
</svg>
</button>
@if (_copied)
@if (Copied)
{
<span class="absolute inset-y-0 right-10 flex items-center pr-3 text-green-500 dark:text-green-400">
Copied!
@@ -27,6 +27,12 @@
</div>
@code {
/// <summary>
/// Id for the input field. Defaults to a random GUID if not provided.
/// </summary>
[Parameter]
public string Id { get; set; } = Guid.NewGuid().ToString();
/// <summary>
/// The label for the input.
/// </summary>
@@ -39,9 +45,9 @@
[Parameter]
public string Value { get; set; } = string.Empty;
private bool _copied => ClipboardCopyService.GetCopiedId() == _inputId;
private readonly string _inputId = Guid.NewGuid().ToString();
private bool _isPasswordVisible = false;
private bool Copied => ClipboardCopyService.GetCopiedId() == Id;
private bool IsPasswordVisible { get; set; }
/// <inheritdoc />
protected override void OnInitialized()
@@ -52,11 +58,11 @@
private async Task CopyToClipboard()
{
await JsInteropService.CopyToClipboard(Value);
ClipboardCopyService.SetCopied(_inputId);
ClipboardCopyService.SetCopied(Id);
// After 2 seconds, reset the copied state if it's still the same element
await Task.Delay(2000);
if (ClipboardCopyService.GetCopiedId() == _inputId)
if (ClipboardCopyService.GetCopiedId() == Id)
{
ClipboardCopyService.SetCopied(string.Empty);
}
@@ -64,7 +70,7 @@
private void TogglePasswordVisibility()
{
_isPasswordVisible = !_isPasswordVisible;
IsPasswordVisible = !IsPasswordVisible;
}
private void HandleCopy(string copiedElementId)

View File

@@ -305,46 +305,6 @@ else
Obj.Password.Value = CredentialService.GenerateRandomPassword();
}
private async Task SaveAlias()
{
GlobalLoadingSpinner.Show();
StateHasChanged();
if (EditMode)
{
if (Id is not null)
{
Id = await CredentialService.UpdateEntryAsync(CredentialEditToCredential(Obj));
}
}
else
{
Id = await CredentialService.InsertEntryAsync(CredentialEditToCredential(Obj));
}
GlobalLoadingSpinner.Hide();
StateHasChanged();
if (Id is null || Id == Guid.Empty)
{
// Error saving.
GlobalNotificationService.AddErrorMessage("Error saving credentials. Please try again.", true);
return;
}
// No error, add success message.
if (EditMode)
{
GlobalNotificationService.AddSuccessMessage("Credential updated successfully.");
}
else
{
GlobalNotificationService.AddSuccessMessage("Credential created successfully.");
}
NavigationManager.NavigateTo("/credentials/" + Id);
}
/// <summary>
/// Helper method to convert a Credential object to a CredentialEdit object.
/// </summary>
@@ -390,7 +350,7 @@ else
/// </summary>
private Credential CredentialEditToCredential(CredentialEdit alias)
{
var credential = new Credential()
var credential = new Credential
{
Id = alias.Id,
Notes = alias.Notes,
@@ -441,4 +401,47 @@ else
await SaveAlias();
}
/// <summary>
/// Save the alias to the database.
/// </summary>
private async Task SaveAlias()
{
GlobalLoadingSpinner.Show();
StateHasChanged();
if (EditMode)
{
if (Id is not null)
{
Id = await CredentialService.UpdateEntryAsync(CredentialEditToCredential(Obj));
}
}
else
{
Id = await CredentialService.InsertEntryAsync(CredentialEditToCredential(Obj));
}
GlobalLoadingSpinner.Hide();
StateHasChanged();
if (Id is null || Id == Guid.Empty)
{
// Error saving.
GlobalNotificationService.AddErrorMessage("Error saving credentials. Please try again.", true);
return;
}
// No error, add success message.
if (EditMode)
{
GlobalNotificationService.AddSuccessMessage("Credential updated successfully.");
}
else
{
GlobalNotificationService.AddSuccessMessage("Credential created successfully.");
}
NavigationManager.NavigateTo("/credentials/" + Id);
}
}

View File

@@ -59,13 +59,13 @@ else
<form action="#">
<div class="grid gap-6">
<div class="col-span-6 sm:col-span-3">
<CopyPasteFormRow Label="Email" Value="@Alias.Alias.Email"></CopyPasteFormRow>
<CopyPasteFormRow Id="email" Label="Email" Value="@Alias.Alias.Email"></CopyPasteFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<CopyPasteFormRow Label="Username" Value="@(Alias.Username)"></CopyPasteFormRow>
<CopyPasteFormRow Id="username" Label="Username" Value="@(Alias.Username)"></CopyPasteFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<CopyPastePasswordFormRow Label="Password" Value="@(Alias.Passwords.FirstOrDefault()?.Value ?? string.Empty)"></CopyPastePasswordFormRow>
<CopyPastePasswordFormRow Id="password" Label="Password" Value="@(Alias.Passwords.FirstOrDefault()?.Value ?? string.Empty)"></CopyPastePasswordFormRow>
</div>
</div>
</form>

View File

@@ -228,25 +228,27 @@ public sealed class CredentialService(HttpClient httpClient, DbService dbService
login.Service.UpdatedAt = DateTime.UtcNow;
// Remove attachments that are no longer in the list
var existingAttachments = login.Attachments.ToList();
foreach (var existingAttachment in existingAttachments)
var attachmentsToRemove = login.Attachments.Where(existingAttachment =>
!loginObject.Attachments.Any(a => a.Id == existingAttachment.Id)).ToList();
foreach (var attachmentToRemove in attachmentsToRemove)
{
if (!loginObject.Attachments.Any(a => a.Id != Guid.Empty && a.Id == existingAttachment.Id))
{
context.Entry(existingAttachment).State = EntityState.Deleted;
}
login.Attachments.Remove(attachmentToRemove);
context.Entry(attachmentToRemove).State = EntityState.Deleted;
}
// Add new attachments
// Update existing attachments and add new ones
foreach (var attachment in loginObject.Attachments)
{
if (!login.Attachments.Any(a => attachment.Id != Guid.Empty && a.Id == attachment.Id))
var existingAttachment = login.Attachments.FirstOrDefault(a => a.Id == attachment.Id);
if (existingAttachment != null)
{
login.Attachments.Add(attachment);
// Update existing attachment
context.Entry(existingAttachment).CurrentValues.SetValues(attachment);
}
else
{
context.Entry(attachment).State = EntityState.Modified;
// Add new attachment
login.Attachments.Add(attachment);
}
}

View File

@@ -118,8 +118,11 @@ public class AttachmentTests : ClientPlaywrightTest
});
// Check that the updated username and attachment name still appear on the alias page.
var usernameElement = await Page.QuerySelectorAsync("#username");
Assert.That(usernameElement, Is.Not.Null, "Username element not found.");
Assert.That(await usernameElement.InputValueAsync(), Is.EqualTo(updatedUsername), "Updated username does not appear on alias page.");
pageContent = await Page.TextContentAsync("body");
Assert.That(pageContent, Does.Contain(updatedUsername), "Updated username does not appear on alias page.");
Assert.That(pageContent, Does.Contain("TestAttachment.txt"), "Attachment name does not appear on alias page after update.");
// Download the attachment
@@ -140,4 +143,63 @@ public class AttachmentTests : ClientPlaywrightTest
// Clean up: delete the downloaded file
File.Delete(downloadedFilePath);
}
/// <summary>
/// Test that uploading and deleting an attachment works correctly.
/// </summary>
/// <returns>Async task.</returns>
[Test]
[Order(3)]
public async Task UploadAndDeleteAttachment()
{
// Create a new alias with service name = "Test Service for Deletion".
var serviceName = "Test Service for Deletion";
await CreateCredentialEntry(
new Dictionary<string, string>
{
{ "service-name", serviceName },
},
async () =>
{
// 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 = "TestAttachment.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);
// Delete the temporary file
File.Delete(tempFilePath);
});
// Check that the attachment name appears on the alias page.
var pageContent = await Page.TextContentAsync("body");
Assert.That(pageContent, Does.Contain("TestAttachment.txt"), "Uploaded attachment name does not appear on alias page.");
// Click the edit button
await Page.ClickAsync("text=Edit");
await WaitForUrlAsync("credentials/**/edit", "Edit the existing credentials");
// Find and click the delete button for the attachment
var deleteButton = Page.Locator("button:has-text('Delete')").First;
await deleteButton.ClickAsync();
// Check that the attachment name no longer appears on the edit page
pageContent = await Page.TextContentAsync("body");
Assert.That(pageContent, Does.Not.Contain("TestAttachment.txt"), "Deleted attachment name still appears on edit page.");
// Save the credential
var saveButton = Page.Locator("text=Save Credentials").First;
await saveButton.ClickAsync();
await WaitForUrlAsync("credentials/**", "Credential updated successfully");
// Check that the attachment name does not appear on the view page
pageContent = await Page.TextContentAsync("body");
Assert.That(pageContent, Does.Not.Contain("TestAttachment.txt"), "Deleted attachment name appears on view page after saving.");
}
}