diff --git a/apps/server/Tests/AliasVault.UnitTests/Utilities/ImportExportTests.cs b/apps/server/Tests/AliasVault.UnitTests/Utilities/ImportExportTests.cs index 6412a583e..f195198fa 100644 --- a/apps/server/Tests/AliasVault.UnitTests/Utilities/ImportExportTests.cs +++ b/apps/server/Tests/AliasVault.UnitTests/Utilities/ImportExportTests.cs @@ -1614,13 +1614,98 @@ public class ImportExportTests Assert.That(cardPin?.Value, Is.EqualTo("1234")); } + /// + /// Test case for round-tripping an item with multiple service URLs through CSV export and import. + /// Multiple URLs are exported as a single comma separated value and split again on import. + /// + /// Async task. + [Test] + public async Task ExportAndImportMultipleServiceUrlsFromCsv() + { + // Arrange: add the URLs out of order to verify they are exported ordered by weight. + var item = new Item + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + Name = "Multi url service", + ItemType = ItemType.Login, + CreatedAt = DateTime.Now, + UpdatedAt = DateTime.Now, + }; + + AddFieldValue(item, FieldKey.LoginUsername, "testuser"); + AddFieldValue(item, FieldKey.LoginUrl, "https://downloads.aliasvault.com", 2); + AddFieldValue(item, FieldKey.LoginUrl, "https://www.aliasvault.com", 0); + AddFieldValue(item, FieldKey.LoginUrl, "https://app.aliasvault.com", 1); + + // Act + var csvString = System.Text.Encoding.Default.GetString(ItemCsvService.ExportItemsToCsv([item])); + var importedCredentials = await ItemCsvService.ImportItemsFromCsv(csvString); + + // Assert: the CSV holds all URLs in a single quoted comma separated column. + Assert.That(csvString, Does.Contain("\"https://www.aliasvault.com,https://app.aliasvault.com,https://downloads.aliasvault.com\"")); + + Assert.That(importedCredentials, Has.Count.EqualTo(1)); + var importedCredential = importedCredentials[0]; + Assert.Multiple(() => + { + Assert.That(importedCredential.ServiceUrls, Has.Count.EqualTo(3)); + Assert.That(importedCredential.ServiceUrls![0], Is.EqualTo("https://www.aliasvault.com")); + Assert.That(importedCredential.ServiceUrls[1], Is.EqualTo("https://app.aliasvault.com")); + Assert.That(importedCredential.ServiceUrls[2], Is.EqualTo("https://downloads.aliasvault.com")); + }); + + // Assert: converting back to an item results in one field value per URL with ascending weights. + var convertedItem = BaseImporter.ConvertToItem(importedCredentials)[0]; + var urlFieldValues = convertedItem.FieldValues.Where(fv => fv.FieldKey == FieldKey.LoginUrl).OrderBy(fv => fv.Weight).ToList(); + Assert.Multiple(() => + { + Assert.That(urlFieldValues, Has.Count.EqualTo(3)); + Assert.That(urlFieldValues[0].Value, Is.EqualTo("https://www.aliasvault.com")); + Assert.That(urlFieldValues[1].Value, Is.EqualTo("https://app.aliasvault.com")); + Assert.That(urlFieldValues[2].Value, Is.EqualTo("https://downloads.aliasvault.com")); + }); + } + + /// + /// Test case for importing a CSV where the ServiceUrl column contains multiple URLs separated by + /// a comma with optional whitespace, as can happen when the file is edited by hand or by a spreadsheet. + /// + /// Async task. + [Test] + public async Task ImportCsvWithMultipleServiceUrls() + { + // Arrange + var csv = + "ServiceName,FolderPath,ServiceUrl,Username,CurrentPassword,AliasEmail,TwoFactorSecret,AliasGender,AliasFirstName,AliasLastName,AliasNickName,AliasBirthDate,Notes,CreatedAt,UpdatedAt\n" + + "Multi url service,,\"https://example.com, https://www.example.com\",user,pass,,,,,,,,,2024-01-01 00:00:00,2024-01-01 00:00:00\n" + + "Single url service,,https://single.example,user,pass,,,,,,,,,2024-01-01 00:00:00,2024-01-01 00:00:00\n"; + + // Act + var importedCredentials = await ItemCsvService.ImportItemsFromCsv(csv); + + // Assert + Assert.That(importedCredentials, Has.Count.EqualTo(2)); + + var multiUrlCredential = importedCredentials.First(c => c.ServiceName == "Multi url service"); + var singleUrlCredential = importedCredentials.First(c => c.ServiceName == "Single url service"); + Assert.Multiple(() => + { + Assert.That(multiUrlCredential.ServiceUrls, Has.Count.EqualTo(2)); + Assert.That(multiUrlCredential.ServiceUrls![0], Is.EqualTo("https://example.com")); + Assert.That(multiUrlCredential.ServiceUrls[1], Is.EqualTo("https://www.example.com")); + Assert.That(singleUrlCredential.ServiceUrls, Has.Count.EqualTo(1)); + Assert.That(singleUrlCredential.ServiceUrls![0], Is.EqualTo("https://single.example")); + }); + } + /// /// Helper method to add a field value to an item. /// /// The item to add the field value to. /// The field key. /// The field value. - private static void AddFieldValue(Item item, string fieldKey, string value) + /// The weight used to order multiple values of the same field key. + private static void AddFieldValue(Item item, string fieldKey, string value, int weight = 0) { item.FieldValues.Add(new FieldValue { @@ -1628,7 +1713,7 @@ public class ImportExportTests ItemId = item.Id, FieldKey = fieldKey, Value = value, - Weight = 0, + Weight = weight, CreatedAt = item.CreatedAt, UpdatedAt = item.UpdatedAt, }); diff --git a/apps/server/Utilities/AliasVault.ImportExport/ItemCsvService.cs b/apps/server/Utilities/AliasVault.ImportExport/ItemCsvService.cs index faf6b9cfe..693179eb4 100644 --- a/apps/server/Utilities/AliasVault.ImportExport/ItemCsvService.cs +++ b/apps/server/Utilities/AliasVault.ImportExport/ItemCsvService.cs @@ -35,7 +35,7 @@ public static class ItemCsvService { ServiceName = item.Name ?? string.Empty, FolderPath = BuildFolderPath(item.Folder), - ServiceUrl = GetFieldValue(item, FieldKey.LoginUrl), + ServiceUrl = GetJoinedFieldValues(item, FieldKey.LoginUrl), Username = GetFieldValue(item, FieldKey.LoginUsername), CurrentPassword = GetFieldValue(item, FieldKey.LoginPassword), AliasEmail = GetFieldValue(item, FieldKey.LoginEmail), @@ -181,6 +181,22 @@ public static class ItemCsvService ?.Value ?? string.Empty; } + /// + /// Gets all values of a multi-value field from an item as a single comma separated string, ordered by weight. + /// + /// The item to get the field values from. + /// The field key to look up. + /// The comma separated field values, or empty string if none are found. + private static string GetJoinedFieldValues(Item item, string fieldKey) + { + var values = item.FieldValues + .Where(fv => fv.FieldKey == fieldKey && !fv.IsDeleted && !string.IsNullOrWhiteSpace(fv.Value)) + .OrderBy(fv => fv.Weight) + .Select(fv => fv.Value!.Trim()); + + return string.Join(",", values); + } + /// /// Returns true if the CSV record has any credit card field populated. /// @@ -234,7 +250,7 @@ public class ItemCsvRecord public string FolderPath { get; set; } = string.Empty; /// - /// Gets or sets the service URL. + /// Gets or sets the service URL(s). Multiple URLs are stored as a comma separated string. /// public string ServiceUrl { get; set; } = string.Empty;