feat(core): import_lastpass — URL/header robustness

Bad URLs in login rows downgrade to url: None with a warning
rather than skipping the row. Header mismatches (extra columns,
wrong order) surface ImportCsvHeader. Quoted commas, multi-line
extra, unicode all parse cleanly via the csv crate's defaults.
This commit is contained in:
adlee-was-taken
2026-04-29 23:09:23 -04:00
parent 0841bddcb5
commit 6f2e868892
2 changed files with 95 additions and 7 deletions

View File

@@ -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}");
}