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;