Files
leetcrypt 4f5939a3e3 pwstore: generic csvManager for 8 password-manager CSV adapters
One data-driven csvManager (layout = header + record func) covers
edge/brave (chrome layout), safari, lastpass, dashlane, protonpass,
nordpass, roboform, and read/guide-only enpass — replacing 8 near-identical
files. Export is header-name driven (parseLoginCSV + new aliases); import
stages a per-manager-layout CSV on tmpfs and shreds it, gated by --allow-csv.
Notes now round-trip through Meta. Reimporter exposes per-manager re-import
hints. Column layouts are MOCK-ONLY (unit-validated) on the same LIVE-VM
staging machinery; recorded as DATA in BROWSER-ROTATION.md §8.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-20 11:31:28 -07:00

215 lines
7.4 KiB
Go

package pwstore
import (
"context"
"encoding/csv"
"os"
"path/filepath"
"strings"
"testing"
"incredigo/internal/vault"
)
// writeCSV writes body to a temp file and returns its path.
func writeCSV(t *testing.T, name, body string) string {
t.Helper()
p := filepath.Join(t.TempDir(), name)
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
return p
}
// TestColsFromHeaderAliases proves the new header aliases resolve the right columns so
// each manager's export parses without a bespoke parser.
func TestColsFromHeaderAliases(t *testing.T) {
cases := []struct {
name string
header []string
// expected resolved indices (-1 if absent)
user, pass, note, group int
}{
{"roboform", []string{"Name", "Url", "MatchUrl", "Login", "Pwd", "Note", "Folder"}, 3, 4, 5, 6},
{"lastpass", []string{"url", "username", "password", "totp", "extra", "name", "grouping", "fav"}, 1, 2, 4, 6},
{"dashlane", []string{"username", "title", "password", "note", "url", "category"}, 0, 2, 3, 5},
{"nordpass", []string{"name", "url", "username", "password", "note", "folder"}, 2, 3, 4, 5},
{"proton", []string{"name", "url", "email", "username", "password", "note", "vault"}, 3, 4, 5, 6},
}
for _, c := range cases {
got := colsFromHeader(c.header)
if got.username != c.user || got.password != c.pass || got.note != c.note || got.group != c.group {
t.Errorf("%s: cols=%+v want user=%d pass=%d note=%d group=%d",
c.name, got, c.user, c.pass, c.note, c.group)
}
}
}
// TestCSVManagerExportParsesAliases reads a representative export for each manager and
// asserts the login fields (incl. note via Meta) come through, with the password as a
// vault handle.
func TestCSVManagerExportParsesAliases(t *testing.T) {
v := vault.New()
defer v.Purge()
cases := []struct {
mgr, file, body string
}{
{"roboform", "rf.csv",
"Name,Url,MatchUrl,Login,Pwd,Note,Folder\n" +
"GitHub,https://github.com/,https://github.com/,alice@example.com,old1,my note,Dev\n"},
{"lastpass", "lp.csv",
"url,username,password,totp,extra,name,grouping,fav\n" +
"https://github.com/,alice@example.com,old1,,my note,GitHub,Dev,0\n"},
{"protonpass", "pp.csv",
"name,url,email,username,password,note,totp,vault\n" +
"GitHub,https://github.com/,alice@personal.com,alice@example.com,old1,my note,,Personal\n"},
}
for _, c := range cases {
path := writeCSV(t, c.file, c.body)
m, err := NewCSV(c.mgr, path)
if err != nil {
t.Fatalf("%s: NewCSV: %v", c.mgr, err)
}
if !m.Available() {
t.Errorf("%s: Available should be true with an existing export", c.mgr)
}
accts, err := m.Export(context.Background(), v)
if err != nil {
t.Fatalf("%s: Export: %v", c.mgr, err)
}
if len(accts) != 1 {
t.Fatalf("%s: got %d accounts, want 1", c.mgr, len(accts))
}
a := accts[0]
if a.Site != "github.com" {
t.Errorf("%s: site = %q, want github.com", c.mgr, a.Site)
}
if a.Username != "alice@example.com" {
t.Errorf("%s: username = %q (alias must resolve the login column, not email)", c.mgr, a.Username)
}
if a.Meta["note"] != "my note" {
t.Errorf("%s: note = %q, want preserved 'my note'", c.mgr, a.Meta["note"])
}
if pw := handleStr(t, v, a.Secret); pw != "old1" {
t.Errorf("%s: password = %q, want old1", c.mgr, pw)
}
}
}
// TestCSVManagerImportStagedLayout proves each manager's staged CSV uses ITS import header,
// carries the substituted NEW password, preserves name/group/note, lands on tmpfs, and is
// shredded on cleanup.
func TestCSVManagerImportStagedLayout(t *testing.T) {
v := vault.New()
defer v.Purge()
accts := []Account{{
URL: "https://github.com/",
Username: "alice@example.com",
Secret: v.Store([]byte("OLD")),
Meta: map[string]string{"name": "GitHub", "group": "Dev", "note": "my note"},
}}
newPw := map[int]*vault.Handle{0: v.Store([]byte("NEW-PASS-1"))}
wantHeader := map[string][]string{
"lastpass": {"url", "username", "password", "totp", "extra", "name", "grouping", "fav"},
"dashlane": {"username", "username2", "username3", "title", "password", "note", "url", "category", "otpSecret"},
"protonpass": {"name", "url", "email", "username", "password", "note", "totp", "vault"},
"nordpass": {"name", "url", "username", "password", "note", "folder"},
"roboform": {"Name", "Url", "MatchUrl", "Login", "Pwd", "Note", "Folder"},
"safari": {"Title", "URL", "Username", "Password", "Notes", "OTPAuth"},
"edge": {"name", "url", "username", "password", "note"},
"brave": {"name", "url", "username", "password", "note"},
}
for mgr, hdr := range wantHeader {
m, err := NewCSV(mgr, "ignored")
if err != nil {
t.Fatalf("%s: NewCSV: %v", mgr, err)
}
si, ok := m.(StagedImporter)
if !ok {
t.Fatalf("%s: not a StagedImporter", mgr)
}
path, cleanup, err := si.ImportStaged(v, accts, newPw)
if err != nil {
t.Fatalf("%s: ImportStaged: %v", mgr, err)
}
if !strings.HasPrefix(path, "/dev/shm/") && !strings.Contains(path, "incredigo") {
t.Errorf("%s: staged path %q not under a tmpfs/incredigo dir", mgr, path)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("%s: read staged: %v", mgr, err)
}
recs, err := csv.NewReader(strings.NewReader(string(raw))).ReadAll()
if err != nil {
t.Fatalf("%s: parse staged: %v", mgr, err)
}
if len(recs) < 2 {
t.Fatalf("%s: staged csv has no data row:\n%s", mgr, raw)
}
if strings.Join(recs[0], ",") != strings.Join(hdr, ",") {
t.Errorf("%s: header = %v, want %v", mgr, recs[0], hdr)
}
row := strings.Join(recs[1], ",")
if !strings.Contains(row, "NEW-PASS-1") {
t.Errorf("%s: row missing new password:\n%s", mgr, row)
}
if strings.Contains(row, "OLD") {
t.Errorf("%s: row must carry the NEW password, not OLD:\n%s", mgr, row)
}
if !strings.Contains(row, "GitHub") {
t.Errorf("%s: row dropped the entry name:\n%s", mgr, row)
}
if !strings.Contains(row, "my note") {
t.Errorf("%s: row dropped the note (round-trip would wipe it):\n%s", mgr, row)
}
cleanup()
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("%s: staged csv not shredded after cleanup: %v", mgr, err)
}
}
}
// TestEnpassImportRefuses proves a manager with no CSV import format refuses ImportStaged
// and points at the guided worklist, while still being readable for the guide.
func TestEnpassImportRefuses(t *testing.T) {
v := vault.New()
defer v.Purge()
m, err := NewCSV("enpass", "ignored")
if err != nil {
t.Fatalf("NewCSV enpass: %v", err)
}
si := m.(StagedImporter)
if _, _, err := si.ImportStaged(v, []Account{{Secret: v.Store([]byte("x"))}}, nil); err == nil {
t.Error("enpass ImportStaged must refuse (no CSV import format)")
}
}
// TestReimportHint proves the manager-specific re-import instruction is exposed.
func TestReimportHint(t *testing.T) {
m, _ := NewCSV("lastpass", "x")
r, ok := m.(Reimporter)
if !ok {
t.Fatal("csv manager should implement Reimporter")
}
if !strings.Contains(strings.ToLower(r.ReimportHint()), "lastpass") {
t.Errorf("reimport hint = %q, want a LastPass-specific instruction", r.ReimportHint())
}
}
// TestNewCSVUnknownRejected proves an unknown name is rejected so the CLI can fall through.
func TestNewCSVUnknownRejected(t *testing.T) {
if _, err := NewCSV("nonsense", "x"); err == nil {
t.Error("NewCSV must reject an unknown manager name")
}
if IsCSV("bitwarden") {
t.Error("bitwarden is a CLI manager, not a CSV one")
}
if !IsCSV("lastpass") {
t.Error("lastpass should be a CSV manager")
}
}