Successful base32 decode attaches a SHA1/6/30s Totp config to LoginCore.totp. Bad base32 emits a warning and imports the login without TOTP rather than skipping the row entirely. Refactors map_row to return (Option<Item>, Option<ImportWarning>) so a single row can produce both an item and a warning. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
158 lines
5.5 KiB
Rust
158 lines
5.5 KiB
Rust
//! LastPass CSV importer — parser coverage.
|
|
|
|
use relicario_core::import_lastpass::{parse_lastpass_csv, ImportWarning};
|
|
use relicario_core::item_types::{TotpAlgorithm, TotpKind};
|
|
use relicario_core::ItemCore;
|
|
|
|
const HEADER: &str = "url,username,password,totp,extra,name,grouping,fav";
|
|
|
|
#[test]
|
|
fn single_login_row_round_trips() {
|
|
let csv = format!(
|
|
"{HEADER}\n\
|
|
https://github.com/login,alice,hunter2,,,GitHub,,",
|
|
);
|
|
let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
|
assert_eq!(items.len(), 1, "one item expected");
|
|
assert!(warnings.is_empty(), "no warnings expected");
|
|
|
|
let item = &items[0];
|
|
assert_eq!(item.title, "GitHub");
|
|
assert!(!item.favorite);
|
|
assert!(item.group.is_none());
|
|
match &item.core {
|
|
ItemCore::Login(l) => {
|
|
assert_eq!(l.username.as_deref(), Some("alice"));
|
|
assert_eq!(l.password.as_deref().map(String::as_str), Some("hunter2"));
|
|
assert_eq!(l.url.as_ref().map(|u| u.as_str()), Some("https://github.com/login"));
|
|
assert!(l.totp.is_none());
|
|
}
|
|
other => panic!("expected Login, got {:?}", other),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn item_id_is_freshly_minted() {
|
|
// Decision D12: title collisions don't dedupe; each row gets a fresh ID.
|
|
let csv = format!("{HEADER}\nhttps://x,u,p,,,Same,,\nhttps://x,u,p,,,Same,,");
|
|
let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
|
assert_eq!(items.len(), 2);
|
|
assert_ne!(items[0].id, items[1].id, "IDs must be unique even for identical names");
|
|
}
|
|
|
|
// Assertion helper used by later tests.
|
|
#[allow(dead_code)]
|
|
fn first_warning_message(warnings: &[ImportWarning]) -> String {
|
|
warnings.first().expect("expected at least one warning").message.clone()
|
|
}
|
|
|
|
#[test]
|
|
fn grouping_maps_to_item_group() {
|
|
let csv = format!("{HEADER}\nhttps://x,u,p,,,Bank,Finance,");
|
|
let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
|
assert!(warnings.is_empty());
|
|
assert_eq!(items[0].group.as_deref(), Some("Finance"));
|
|
}
|
|
|
|
#[test]
|
|
fn empty_grouping_yields_none() {
|
|
let csv = format!("{HEADER}\nhttps://x,u,p,,,Bank,,");
|
|
let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
|
assert!(items[0].group.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn fav_one_marks_favorite() {
|
|
let csv = format!("{HEADER}\nhttps://x,u,p,,,Bank,,1");
|
|
let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
|
assert!(items[0].favorite);
|
|
}
|
|
|
|
#[test]
|
|
fn fav_zero_or_blank_not_favorite() {
|
|
let csv = format!(
|
|
"{HEADER}\n\
|
|
https://x,u,p,,,Zero,,0\n\
|
|
https://x,u,p,,,Blank,,",
|
|
);
|
|
let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
|
assert_eq!(items.len(), 2);
|
|
assert!(!items[0].favorite);
|
|
assert!(!items[1].favorite);
|
|
}
|
|
|
|
#[test]
|
|
fn extra_becomes_notes_for_login() {
|
|
let csv = format!("{HEADER}\nhttps://x,u,p,,a hint,Bank,,");
|
|
let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
|
assert_eq!(items[0].notes.as_deref(), Some("a hint"));
|
|
}
|
|
|
|
#[test]
|
|
fn multiline_extra_round_trips_via_quoting() {
|
|
// CSV double-quotes escape embedded newlines.
|
|
let csv = format!(
|
|
"{HEADER}\n\
|
|
https://x,u,p,,\"line1\nline2\nline3\",Bank,,",
|
|
);
|
|
let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
|
assert!(warnings.is_empty(), "multi-line extra should parse cleanly");
|
|
assert_eq!(items[0].notes.as_deref(), Some("line1\nline2\nline3"));
|
|
}
|
|
|
|
#[test]
|
|
fn login_with_valid_totp_secret_attaches_config() {
|
|
// RFC 4648 base32 of b"12345678901234567890" → "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ".
|
|
let csv = format!(
|
|
"{HEADER}\n\
|
|
https://github.com/login,alice,hunter2,GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ,,GitHub,,",
|
|
);
|
|
let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
|
assert!(warnings.is_empty());
|
|
match &items[0].core {
|
|
ItemCore::Login(l) => {
|
|
let totp = l.totp.as_ref().expect("expected TOTP config");
|
|
assert_eq!(totp.algorithm, TotpAlgorithm::Sha1);
|
|
assert_eq!(totp.digits, 6);
|
|
assert_eq!(totp.period_seconds, 30);
|
|
assert_eq!(totp.kind, TotpKind::Totp);
|
|
assert_eq!(totp.secret.as_slice(), b"12345678901234567890");
|
|
}
|
|
other => panic!("expected Login, got {:?}", other),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn login_with_bad_totp_secret_imports_without_totp_and_warns() {
|
|
let csv = format!(
|
|
"{HEADER}\n\
|
|
https://github.com/login,alice,hunter2,!!!!not-base32!!!!,,GitHub,,",
|
|
);
|
|
let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
|
assert_eq!(items.len(), 1, "login should still import");
|
|
match &items[0].core {
|
|
ItemCore::Login(l) => assert!(l.totp.is_none(), "TOTP must be dropped"),
|
|
other => panic!("expected Login, got {:?}", other),
|
|
}
|
|
assert_eq!(warnings.len(), 1);
|
|
let w = &warnings[0];
|
|
assert_eq!(w.title.as_deref(), Some("GitHub"));
|
|
assert!(w.message.contains("TOTP"), "message: {}", w.message);
|
|
assert!(w.message.contains("invalid") || w.message.contains("base32"));
|
|
}
|
|
|
|
#[test]
|
|
fn login_with_lowercase_base32_totp_is_accepted() {
|
|
// RFC 4648 is case-insensitive; LastPass exports may use either case.
|
|
let csv = format!(
|
|
"{HEADER}\n\
|
|
https://x,u,p,gezdgnbvgy3tqojqgezdgnbvgy3tqojq,,Acme,,",
|
|
);
|
|
let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
|
assert!(warnings.is_empty(), "lowercase base32 must parse");
|
|
match &items[0].core {
|
|
ItemCore::Login(l) => assert!(l.totp.is_some()),
|
|
_ => unreachable!(),
|
|
}
|
|
}
|