diff --git a/crates/relicario-cli/tests/fixtures/lastpass-sample.csv b/crates/relicario-cli/tests/fixtures/lastpass-sample.csv new file mode 100644 index 0000000..84fd156 --- /dev/null +++ b/crates/relicario-cli/tests/fixtures/lastpass-sample.csv @@ -0,0 +1,17 @@ +url,username,password,totp,extra,name,grouping,fav +https://github.com/login,alice@example.com,hunter2-strong,GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ,One-time URL: https://github.com/recover,GitHub,Work,1 +https://gmail.com,bob@example.com,p@ssw0rd-2026,,,Gmail,Personal, +https://news.ycombinator.com,charlie,hn-secret,,,Hacker News,, +https://aws.console,d-user,aws-pass,!!!not-base32!!!,,AWS,Work, +http://sn,,,,Wifi password: hunter2hunter2,Home Wifi,Personal, +http://sn,,,,"NoteType:Credit Card +Number:4111111111111111 +Expiry:01/2030 +CVV:123",Visa Card,Personal, +https://日本語.example,user,pass,,,日本語サイト,, +not-a-real-url,user,pass,,,Bad URL,, +,,,,,,, +https://x,user,,,,No Password,, +https://example.com,user,p,,"multi +line +notes",Multiline,, diff --git a/crates/relicario-cli/tests/import_lastpass.rs b/crates/relicario-cli/tests/import_lastpass.rs new file mode 100644 index 0000000..843a161 --- /dev/null +++ b/crates/relicario-cli/tests/import_lastpass.rs @@ -0,0 +1,127 @@ +mod common; +use common::TestVault; + +const FIXTURE: &str = "tests/fixtures/lastpass-sample.csv"; + +fn fixture_path() -> std::path::PathBuf { + // Manifest dir = crates/relicario-cli; the fixture is relative to it. + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(FIXTURE) +} + +#[test] +fn imports_logins_secure_notes_and_warns_on_skipped() { + let v = TestVault::init(); + + let out = v.run(&["import", "lastpass", fixture_path().to_str().unwrap()]); + assert!( + out.status.success(), + "import failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); + + let stderr = String::from_utf8(out.stderr).unwrap(); + // 9 items expected (see fixture comment). + assert!(stderr.contains("Imported 9"), "stderr: {stderr}"); + assert!(stderr.contains("skipped 2"), "stderr: {stderr}"); + + // Each warning surfaces. + assert!(stderr.contains("invalid base32 TOTP"), "TOTP warning missing"); + assert!(stderr.contains("invalid URL"), "URL warning missing"); + assert!(stderr.contains("missing `name`"), "name-missing warning missing"); + assert!(stderr.contains("missing `password`"), "password-missing warning missing"); +} + +#[test] +fn list_after_import_shows_imported_titles() { + let v = TestVault::init(); + v.run(&["import", "lastpass", fixture_path().to_str().unwrap()]); + + let out = v.run(&["list"]); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert!(stdout.contains("GitHub")); + assert!(stdout.contains("Gmail")); + assert!(stdout.contains("Home Wifi")); + assert!(stdout.contains("Visa Card")); + assert!(stdout.contains("日本語サイト")); + // Skipped rows must NOT appear. + assert!(!stdout.contains("No Password"), + "row with no password should have been skipped"); +} + +#[test] +fn import_creates_a_single_git_commit() { + let v = TestVault::init(); + + // Count commits before. + let before = std::process::Command::new("git") + .arg("-C").arg(v.path()) + .args(["rev-list", "--count", "HEAD"]) + .output().unwrap(); + let before_n: u32 = String::from_utf8(before.stdout).unwrap().trim().parse().unwrap(); + + v.run(&["import", "lastpass", fixture_path().to_str().unwrap()]); + + let after = std::process::Command::new("git") + .arg("-C").arg(v.path()) + .args(["rev-list", "--count", "HEAD"]) + .output().unwrap(); + let after_n: u32 = String::from_utf8(after.stdout).unwrap().trim().parse().unwrap(); + + assert_eq!(after_n, before_n + 1, "expected exactly one new commit"); + + // Commit message includes the count + "LastPass". + let log = std::process::Command::new("git") + .arg("-C").arg(v.path()) + .args(["log", "-1", "--pretty=%s"]) + .output().unwrap(); + let subject = String::from_utf8(log.stdout).unwrap(); + assert!(subject.contains("9 items")); + assert!(subject.contains("LastPass")); +} + +#[test] +fn import_with_zero_items_exits_nonzero() { + let v = TestVault::init(); + + // Header-only CSV with one bad row → 0 items. + let bad_csv = v.path().join("empty.csv"); + std::fs::write( + &bad_csv, + "url,username,password,totp,extra,name,grouping,fav\n,,,,,,,\n", + ).unwrap(); + + let out = v.run(&["import", "lastpass", bad_csv.to_str().unwrap()]); + assert!(!out.status.success(), "expected non-zero exit on zero items"); + let stderr = String::from_utf8(out.stderr).unwrap(); + assert!(stderr.contains("imported 0 items"), "stderr: {stderr}"); +} + +#[test] +fn import_rejects_unrecognized_header() { + let v = TestVault::init(); + let bad_csv = v.path().join("wrong.csv"); + std::fs::write(&bad_csv, "name,url,user,pass\nA,https://x,u,p\n").unwrap(); + + let out = v.run(&["import", "lastpass", bad_csv.to_str().unwrap()]); + assert!(!out.status.success()); + let stderr = String::from_utf8(out.stderr).unwrap(); + assert!( + stderr.contains("LastPass") || stderr.contains("expected"), + "stderr: {stderr}", + ); +} + +#[test] +fn imported_items_keep_unique_ids_across_runs() { + // Decision D12: two imports of the same CSV must not collide. + let v = TestVault::init(); + v.run(&["import", "lastpass", fixture_path().to_str().unwrap()]); + v.run(&["import", "lastpass", fixture_path().to_str().unwrap()]); + + let out = v.run(&["list"]); + let stdout = String::from_utf8(out.stdout).unwrap(); + // Each title imported twice — count occurrences of "GitHub" must be 2. + let github_count = stdout.matches("GitHub").count(); + assert_eq!(github_count, 2, "stdout: {stdout}"); +}