diff --git a/apps/mobile-app/app/(tabs)/settings/import-export.tsx b/apps/mobile-app/app/(tabs)/settings/import-export.tsx index f78f2afe7..dc0632b8b 100644 --- a/apps/mobile-app/app/(tabs)/settings/import-export.tsx +++ b/apps/mobile-app/app/(tabs)/settings/import-export.tsx @@ -35,6 +35,12 @@ interface IItemCsvRecord { AliasLastName: string; AliasNickName: string; AliasBirthDate: string; + CardholderName: string; + CardNumber: string; + CardExpiryMonth: string; + CardExpiryYear: string; + CardCvv: string; + CardPin: string; Notes: string; CreatedAt: string; UpdatedAt: string; @@ -127,6 +133,12 @@ export default function ImportExportScreen(): React.ReactNode { const aliasLastName = getFieldValueAsString(item, 'alias.lastName'); const aliasBirthdate = getFieldValueAsString(item, 'alias.birthdate'); const notes = getFieldValueAsString(item, 'notes.content'); + const cardholderName = getFieldValueAsString(item, 'card.cardholder_name'); + const cardNumber = getFieldValueAsString(item, 'card.number'); + const cardExpiryMonth = getFieldValueAsString(item, 'card.expiry_month'); + const cardExpiryYear = getFieldValueAsString(item, 'card.expiry_year'); + const cardCvv = getFieldValueAsString(item, 'card.cvv'); + const cardPin = getFieldValueAsString(item, 'card.pin'); // Parse birthdate to formatted string (server expects MM/DD/YYYY format in CSV) const formattedBirthDate = aliasBirthdate ? formatDate(aliasBirthdate) : ''; @@ -144,6 +156,12 @@ export default function ImportExportScreen(): React.ReactNode { AliasLastName: aliasLastName, AliasNickName: '', // NickName is no longer stored as a separate field AliasBirthDate: formattedBirthDate, + CardholderName: cardholderName, + CardNumber: cardNumber, + CardExpiryMonth: cardExpiryMonth, + CardExpiryYear: cardExpiryYear, + CardCvv: cardCvv, + CardPin: cardPin, Notes: notes, CreatedAt: formatDate(item.CreatedAt), UpdatedAt: formatDate(item.UpdatedAt) @@ -166,6 +184,12 @@ export default function ImportExportScreen(): React.ReactNode { 'AliasLastName', 'AliasNickName', 'AliasBirthDate', + 'CardholderName', + 'CardNumber', + 'CardExpiryMonth', + 'CardExpiryYear', + 'CardCvv', + 'CardPin', 'Notes', 'CreatedAt', 'UpdatedAt' @@ -202,6 +226,12 @@ export default function ImportExportScreen(): React.ReactNode { escapeCsvValue(record.AliasLastName), escapeCsvValue(record.AliasNickName), escapeCsvValue(record.AliasBirthDate), + escapeCsvValue(record.CardholderName), + escapeCsvValue(record.CardNumber), + escapeCsvValue(record.CardExpiryMonth), + escapeCsvValue(record.CardExpiryYear), + escapeCsvValue(record.CardCvv), + escapeCsvValue(record.CardPin), escapeCsvValue(record.Notes), escapeCsvValue(record.CreatedAt), escapeCsvValue(record.UpdatedAt) diff --git a/apps/server/Tests/AliasVault.UnitTests/Utilities/ImportExportTests.cs b/apps/server/Tests/AliasVault.UnitTests/Utilities/ImportExportTests.cs index 66e7b2aa0..9c0541a5a 100644 --- a/apps/server/Tests/AliasVault.UnitTests/Utilities/ImportExportTests.cs +++ b/apps/server/Tests/AliasVault.UnitTests/Utilities/ImportExportTests.cs @@ -79,6 +79,115 @@ public class ImportExportTests }); } + /// + /// Test case for round-tripping a credit card item through CSV export and import. + /// Verifies that card-specific columns are populated on export and that the + /// importer recognizes the item as a credit card. + /// + /// Async task. + [Test] + public async Task ImportCreditCardItemFromCsv() + { + // Arrange + var loginItem = new Item + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + Name = "Login service", + ItemType = ItemType.Login, + CreatedAt = DateTime.Now, + UpdatedAt = DateTime.Now, + }; + AddFieldValue(loginItem, FieldKey.LoginUsername, "loginuser"); + AddFieldValue(loginItem, FieldKey.LoginPassword, "loginpass"); + + var cardItem = new Item + { + Id = new Guid("00000000-0000-0000-0000-000000000002"), + Name = "My Visa", + ItemType = ItemType.CreditCard, + CreatedAt = DateTime.Now, + UpdatedAt = DateTime.Now, + }; + AddFieldValue(cardItem, FieldKey.CardCardholderName, "John Doe"); + AddFieldValue(cardItem, FieldKey.CardNumber, "4111111111111111"); + AddFieldValue(cardItem, FieldKey.CardExpiryMonth, "12"); + AddFieldValue(cardItem, FieldKey.CardExpiryYear, "2030"); + AddFieldValue(cardItem, FieldKey.CardCvv, "123"); + AddFieldValue(cardItem, FieldKey.CardPin, "9876"); + AddFieldValue(cardItem, FieldKey.NotesContent, "Card notes"); + + var csvContent = ItemCsvService.ExportItemsToCsv([loginItem, cardItem]); + var csvString = System.Text.Encoding.Default.GetString(csvContent); + + // Assert: header includes the new card columns + var headerLine = csvString.Split('\n')[0]; + Assert.Multiple(() => + { + Assert.That(headerLine, Does.Contain("CardholderName")); + Assert.That(headerLine, Does.Contain("CardNumber")); + Assert.That(headerLine, Does.Contain("CardExpiryMonth")); + Assert.That(headerLine, Does.Contain("CardExpiryYear")); + Assert.That(headerLine, Does.Contain("CardCvv")); + Assert.That(headerLine, Does.Contain("CardPin")); + }); + + // Act + var importedCredentials = await ItemCsvService.ImportItemsFromCsv(csvString); + + // Assert + Assert.That(importedCredentials, Has.Count.EqualTo(2)); + + var importedLogin = importedCredentials.First(c => c.ServiceName == "Login service"); + Assert.Multiple(() => + { + Assert.That(importedLogin.ItemType, Is.Null, "Login items should not have ItemType set by CSV importer."); + Assert.That(importedLogin.Creditcard, Is.Null); + Assert.That(importedLogin.Username, Is.EqualTo("loginuser")); + }); + + var importedCard = importedCredentials.First(c => c.ServiceName == "My Visa"); + Assert.Multiple(() => + { + Assert.That(importedCard.ItemType, Is.EqualTo(ImportedItemType.Creditcard)); + Assert.That(importedCard.Creditcard, Is.Not.Null); + Assert.That(importedCard.Creditcard!.CardholderName, Is.EqualTo("John Doe")); + Assert.That(importedCard.Creditcard.Number, Is.EqualTo("4111111111111111")); + Assert.That(importedCard.Creditcard.ExpiryMonth, Is.EqualTo("12")); + Assert.That(importedCard.Creditcard.ExpiryYear, Is.EqualTo("2030")); + Assert.That(importedCard.Creditcard.Cvv, Is.EqualTo("123")); + Assert.That(importedCard.Creditcard.Pin, Is.EqualTo("9876")); + Assert.That(importedCard.Notes, Is.EqualTo("Card notes")); + }); + } + + /// + /// Verifies that CSV files exported by older versions (without credit card columns) + /// can still be imported successfully. + /// + /// Async task. + [Test] + public async Task ImportLegacyCsvWithoutCreditCardColumns() + { + // Arrange: a CSV with the pre-card column set only. + var legacyCsv = + "ServiceName,FolderPath,ServiceUrl,Username,CurrentPassword,AliasEmail,TwoFactorSecret,AliasGender,AliasFirstName,AliasLastName,AliasNickName,AliasBirthDate,Notes,CreatedAt,UpdatedAt\n" + + "Old Service,,https://old.example,olduser,oldpass,,,,,,,,,2024-01-01 00:00:00,2024-01-01 00:00:00\n"; + + // Act + var importedCredentials = await ItemCsvService.ImportItemsFromCsv(legacyCsv); + + // Assert + Assert.That(importedCredentials, Has.Count.EqualTo(1)); + var credential = importedCredentials[0]; + Assert.Multiple(() => + { + Assert.That(credential.ServiceName, Is.EqualTo("Old Service")); + Assert.That(credential.Username, Is.EqualTo("olduser")); + Assert.That(credential.ItemType, Is.Null); + Assert.That(credential.Creditcard, Is.Null); + }); + } + /// /// Test case for importing credentials from Bitwarden CSV and ensuring all values are present. /// diff --git a/apps/server/Utilities/AliasVault.ImportExport/ItemCsvService.cs b/apps/server/Utilities/AliasVault.ImportExport/ItemCsvService.cs index 4f426c125..faf6b9cfe 100644 --- a/apps/server/Utilities/AliasVault.ImportExport/ItemCsvService.cs +++ b/apps/server/Utilities/AliasVault.ImportExport/ItemCsvService.cs @@ -48,6 +48,12 @@ public static class ItemCsvService Notes = GetFieldValue(item, FieldKey.NotesContent), CreatedAt = item.CreatedAt, UpdatedAt = item.UpdatedAt, + CardholderName = GetFieldValue(item, FieldKey.CardCardholderName), + CardNumber = GetFieldValue(item, FieldKey.CardNumber), + CardExpiryMonth = GetFieldValue(item, FieldKey.CardExpiryMonth), + CardExpiryYear = GetFieldValue(item, FieldKey.CardExpiryYear), + CardCvv = GetFieldValue(item, FieldKey.CardCvv), + CardPin = GetFieldValue(item, FieldKey.CardPin), }; records.Add(record); @@ -117,6 +123,20 @@ public static class ItemCsvService FolderPath = string.IsNullOrWhiteSpace(record.FolderPath) ? null : record.FolderPath, }; + if (HasCardData(record)) + { + credential.ItemType = ImportedItemType.Creditcard; + credential.Creditcard = new ImportedCreditcard + { + CardholderName = record.CardholderName, + Number = record.CardNumber, + ExpiryMonth = record.CardExpiryMonth, + ExpiryYear = record.CardExpiryYear, + Cvv = record.CardCvv, + Pin = record.CardPin, + }; + } + credentials.Add(credential); } @@ -161,6 +181,21 @@ public static class ItemCsvService ?.Value ?? string.Empty; } + /// + /// Returns true if the CSV record has any credit card field populated. + /// + /// The CSV record to inspect. + /// True if any card field has a non-empty value. + private static bool HasCardData(ItemCsvRecord record) + { + return !string.IsNullOrWhiteSpace(record.CardholderName) + || !string.IsNullOrWhiteSpace(record.CardNumber) + || !string.IsNullOrWhiteSpace(record.CardExpiryMonth) + || !string.IsNullOrWhiteSpace(record.CardExpiryYear) + || !string.IsNullOrWhiteSpace(record.CardCvv) + || !string.IsNullOrWhiteSpace(record.CardPin); + } + /// /// Parses a birth date string to a DateTime. /// @@ -248,6 +283,42 @@ public class ItemCsvRecord /// public DateTime? AliasBirthDate { get; set; } = null; + /// + /// Gets or sets the credit card cardholder name. + /// Empty for non-credit-card items. + /// + public string CardholderName { get; set; } = string.Empty; + + /// + /// Gets or sets the credit card number. + /// Empty for non-credit-card items. + /// + public string CardNumber { get; set; } = string.Empty; + + /// + /// Gets or sets the credit card expiry month (01-12). + /// Empty for non-credit-card items. + /// + public string CardExpiryMonth { get; set; } = string.Empty; + + /// + /// Gets or sets the credit card expiry year (e.g., "2028"). + /// Empty for non-credit-card items. + /// + public string CardExpiryYear { get; set; } = string.Empty; + + /// + /// Gets or sets the credit card CVV. + /// Empty for non-credit-card items. + /// + public string CardCvv { get; set; } = string.Empty; + + /// + /// Gets or sets the credit card PIN. + /// Empty for non-credit-card items. + /// + public string CardPin { get; set; } = string.Empty; + /// /// Gets or sets the notes. ///