//! 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!(), } } #[test] fn url_http_sn_maps_to_secure_note() { let csv = format!( "{HEADER}\n\ http://sn,,,,The body of the note,My Note,,", ); let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap(); assert!(warnings.is_empty()); assert_eq!(items.len(), 1); assert_eq!(items[0].title, "My Note"); match &items[0].core { ItemCore::SecureNote(sn) => assert_eq!(sn.body.as_str(), "The body of the note"), other => panic!("expected SecureNote, got {:?}", other), } } #[test] fn secure_note_does_not_require_password() { // SecureNote rows have empty password; that must not trigger the // `missing password` skip path (which is Login-only). let csv = format!("{HEADER}\nhttp://sn,,,,note text,Title,,"); let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap(); assert!(warnings.is_empty(), "{:?}", warnings); assert_eq!(items.len(), 1); } #[test] fn secure_note_passes_through_grouping_and_favorite() { let csv = format!("{HEADER}\nhttp://sn,,,,body,Title,Personal,1"); let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap(); assert_eq!(items[0].group.as_deref(), Some("Personal")); assert!(items[0].favorite); } #[test] fn secure_note_preserves_structured_extra_verbatim() { // LastPass packs structured note data (e.g. credit cards) into `extra` // using their own key:value format. We do NOT auto-parse it — verbatim // pass-through, per spec D10. let csv_body = "NoteType:Credit Card\nNumber:4111111111111111\nCVV:123"; let csv = format!( "{HEADER}\n\ http://sn,,,,\"{csv_body}\",Visa,,", csv_body = csv_body, ); let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap(); match &items[0].core { ItemCore::SecureNote(sn) => assert_eq!(sn.body.as_str(), csv_body), _ => unreachable!(), } }