Add credit card fields to basic CSV export (#1946)

This commit is contained in:
Leendert de Borst
2026-04-26 20:26:01 +02:00
committed by Leendert de Borst
parent 5fa191bb43
commit 98f0eef484
3 changed files with 210 additions and 0 deletions

View File

@@ -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)

View File

@@ -79,6 +79,115 @@ public class ImportExportTests
});
}
/// <summary>
/// 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.
/// </summary>
/// <returns>Async task.</returns>
[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"));
});
}
/// <summary>
/// Verifies that CSV files exported by older versions (without credit card columns)
/// can still be imported successfully.
/// </summary>
/// <returns>Async task.</returns>
[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);
});
}
/// <summary>
/// Test case for importing credentials from Bitwarden CSV and ensuring all values are present.
/// </summary>

View File

@@ -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;
}
/// <summary>
/// Returns true if the CSV record has any credit card field populated.
/// </summary>
/// <param name="record">The CSV record to inspect.</param>
/// <returns>True if any card field has a non-empty value.</returns>
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);
}
/// <summary>
/// Parses a birth date string to a DateTime.
/// </summary>
@@ -248,6 +283,42 @@ public class ItemCsvRecord
/// </summary>
public DateTime? AliasBirthDate { get; set; } = null;
/// <summary>
/// Gets or sets the credit card cardholder name.
/// Empty for non-credit-card items.
/// </summary>
public string CardholderName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the credit card number.
/// Empty for non-credit-card items.
/// </summary>
public string CardNumber { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the credit card expiry month (01-12).
/// Empty for non-credit-card items.
/// </summary>
public string CardExpiryMonth { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the credit card expiry year (e.g., "2028").
/// Empty for non-credit-card items.
/// </summary>
public string CardExpiryYear { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the credit card CVV.
/// Empty for non-credit-card items.
/// </summary>
public string CardCvv { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the credit card PIN.
/// Empty for non-credit-card items.
/// </summary>
public string CardPin { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the notes.
/// </summary>