diff --git a/crates/relicario-core/src/import_lastpass.rs b/crates/relicario-core/src/import_lastpass.rs index e206100..5916b34 100644 --- a/crates/relicario-core/src/import_lastpass.rs +++ b/crates/relicario-core/src/import_lastpass.rs @@ -132,9 +132,27 @@ fn map_row( })); } - let parsed_url = if url.is_empty() { None } else { Url::parse(url).ok() }; - let mut warning: Option = None; + + let parsed_url = if url.is_empty() { + None + } else { + match Url::parse(url) { + Ok(u) => Some(u), + Err(_) => { + // Login still imports — URL becomes None, with a warning. + if warning.is_none() { + warning = Some(ImportWarning { + row, + title: Some(name.to_string()), + message: format!("invalid URL `{url}` — login imported without URL"), + }); + } + None + } + } + }; + let totp = if totp_raw.is_empty() { None } else { @@ -147,11 +165,14 @@ fn map_row( kind: crate::item_types::TotpKind::Totp, }), _ => { - warning = Some(ImportWarning { - row, - title: Some(name.to_string()), - message: "invalid base32 TOTP secret — login imported without TOTP".into(), - }); + if warning.is_none() { + warning = Some(ImportWarning { + row, + title: Some(name.to_string()), + message: "invalid base32 TOTP secret — login imported without TOTP" + .into(), + }); + } None } } diff --git a/crates/relicario-core/tests/import_lastpass.rs b/crates/relicario-core/tests/import_lastpass.rs index c1cf39b..7cda219 100644 --- a/crates/relicario-core/tests/import_lastpass.rs +++ b/crates/relicario-core/tests/import_lastpass.rs @@ -207,3 +207,70 @@ fn secure_note_preserves_structured_extra_verbatim() { _ => unreachable!(), } } + +#[test] +fn login_with_unparseable_url_imports_with_url_none_and_warns() { + let csv = format!( + "{HEADER}\n\ + not-a-real-url,alice,hunter2,,,Site,,", + ); + let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap(); + assert_eq!(items.len(), 1); + match &items[0].core { + ItemCore::Login(l) => assert!(l.url.is_none()), + _ => unreachable!(), + } + assert_eq!(warnings.len(), 1); + assert!(warnings[0].message.contains("URL"), "msg: {}", warnings[0].message); + assert_eq!(warnings[0].title.as_deref(), Some("Site")); +} + +#[test] +fn header_with_extra_column_is_rejected() { + let bad = "url,username,password,totp,extra,name,grouping,fav,EXTRA\nhttps://x,u,p,,,T,,"; + let err = parse_lastpass_csv(bad.as_bytes()).unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("LastPass") || msg.contains("expected"), "msg: {msg}"); +} + +#[test] +fn header_with_wrong_column_order_is_rejected() { + let swapped = "name,url,username,password,totp,extra,grouping,fav\nT,https://x,u,p,,,,"; + let err = parse_lastpass_csv(swapped.as_bytes()).unwrap_err(); + assert!(format!("{err}").contains("expected")); +} + +#[test] +fn quoted_comma_in_extra_parses() { + let csv = format!( + "{HEADER}\n\ + https://x,u,p,,\"hint with, a comma\",Site,,", + ); + let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap(); + assert!(warnings.is_empty()); + assert_eq!(items[0].notes.as_deref(), Some("hint with, a comma")); +} + +#[test] +fn unicode_title_round_trips() { + let csv = format!("{HEADER}\nhttps://x,u,p,,,Müllerstraße — café ☕,,"); + let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap(); + assert_eq!(items[0].title, "Müllerstraße — café ☕"); +} + +#[test] +fn empty_csv_after_header_returns_empty_vecs() { + let (items, warnings) = parse_lastpass_csv(HEADER.as_bytes()).unwrap(); + assert!(items.is_empty()); + assert!(warnings.is_empty()); +} + +#[test] +fn missing_header_is_rejected() { + // Empty input — csv reader treats first row as header (which doesn't exist). + let err = parse_lastpass_csv(b"").unwrap_err(); + let msg = format!("{err}"); + // Either ImportCsvHeader (header didn't match) or ImportCsvFormat (read + // failed). Both are acceptable; we just need a clear error. + assert!(msg.contains("LastPass") || msg.contains("CSV"), "msg: {msg}"); +}