diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-18 08:11:14 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-18 08:11:14 +0300 |
| commit | e9ba8589491fcaae475627f0dd613b4ba21ee442 (patch) | |
| tree | cfab03892707cbda32cef05df55fa9218a65880a /internal | |
| parent | 58349705d5adafa60b8a1dddd0f5c72bad568d3b (diff) | |
test: add integration tests for keepass backend, improve coverage to 81.6% (task q4)
Add 430 lines of new integration tests covering WriteBack edit round-trip,
Search action handlers (cat/export), ShredAllExported, ImportRecursive,
TestParsePickerAction (all 10 fzf keys), TestBuildPickerEntries with RowID
assertions, ImportForce, ensureRootGroup edge cases, and a captureStdout
helper. Add TTY-unavailability comment to FzfInteractive explaining why
Fzf/FzfInteractive/runFzfInteractive have no automated tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/keepass/fzf.go | 6 | ||||
| -rw-r--r-- | internal/keepass/keepass_test.go | 423 |
2 files changed, 429 insertions, 0 deletions
diff --git a/internal/keepass/fzf.go b/internal/keepass/fzf.go index a30e769..4bf1cf3 100644 --- a/internal/keepass/fzf.go +++ b/internal/keepass/fzf.go @@ -20,6 +20,12 @@ func (b *Backend) Fzf(ctx context.Context) (string, error) { // FzfInteractive launches fzf with action key bindings and returns the // selected description plus the chosen action. +// +// Not covered by automated tests: requires an interactive TTY and the fzf +// binary to be present — both are unavailable in CI. Coverage is provided by +// manual testing and by the unit tests for buildPickerEntries and +// parsePickerAction, which exercise the data-preparation and key-mapping logic +// that surrounds the fzf call. func (b *Backend) FzfInteractive(ctx context.Context) (store.PickerResult, error) { var indexes store.IndexSlice if err := b.WalkIndexes(ctx, "", func(idx *store.Index) error { diff --git a/internal/keepass/keepass_test.go b/internal/keepass/keepass_test.go index dcd8712..5a87514 100644 --- a/internal/keepass/keepass_test.go +++ b/internal/keepass/keepass_test.go @@ -492,6 +492,429 @@ func TestAddThenRemoveAttachment(t *testing.T) { } } +// TestWriteBackEditRoundtrip verifies that makeWriteBack returns a function that +// parses updated content back into KeePass fields and persists them. This +// exercises the edit workflow: export → external editor modifies content → +// WriteBack re-imports the new fields into the database. +func TestWriteBackEditRoundtrip(t *testing.T) { + dbPath := createTestDB(t) + b := newTestBackend(t, dbPath) + ctx := context.Background() + + // Load the entry to get a Data with a populated WriteBack hook. + idx := &store.Index{Description: "Work/Email"} + d, err := b.LoadData(ctx, idx) + if err != nil { + t.Fatalf("LoadData: %v", err) + } + if d.WriteBack == nil { + t.Fatal("LoadData must set WriteBack for keepass text entries") + } + + // Simulate an external editor: produce new content with changed fields. + newContent := formatContent("newpass", "bob", "https://new.example.com", "updated notes") + if err := d.WriteBack(newContent); err != nil { + t.Fatalf("WriteBack: %v", err) + } + + // Re-open and verify the fields persisted correctly. + b2 := newTestBackend(t, dbPath) + d2, err := b2.LoadData(ctx, idx) + if err != nil { + t.Fatalf("LoadData after WriteBack: %v", err) + } + content := string(d2.Content) + for _, want := range []string{"Password: newpass", "User: bob", "URL: https://new.example.com", "updated notes"} { + if !strings.Contains(content, want) { + t.Errorf("content after WriteBack missing %q; got: %q", want, content) + } + } +} + +// captureStdout redirects os.Stdout to a pipe, runs fn, then restores +// os.Stdout and returns all bytes written during fn. It calls t.Fatal if the +// pipe cannot be created. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + orig := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("captureStdout: pipe: %v", err) + } + os.Stdout = w + fn() + w.Close() + os.Stdout = orig + var buf strings.Builder + tmp := make([]byte, 4096) + for { + n, _ := r.Read(tmp) + if n == 0 { + break + } + buf.Write(tmp[:n]) + } + return buf.String() +} + +// TestSearchActionCat verifies that Search with ActionCat calls actionCat for +// each matching text entry (printing content to stdout) and does not error. +func TestSearchActionCat(t *testing.T) { + dbPath := createTestDB(t) + b := newTestBackend(t, dbPath) + ctx := context.Background() + + var indexes []*store.Index + var searchErr error + out := captureStdout(t, func() { + indexes, searchErr = b.Search(ctx, "Work/Email", store.ActionCat, nil, nil) + }) + + if searchErr != nil { + t.Fatalf("Search(ActionCat): %v", searchErr) + } + if len(indexes) == 0 { + t.Fatal("expected at least one result") + } + if !strings.Contains(out, "Password: secret") { + t.Errorf("cat output missing 'Password: secret'; got: %q", out) + } +} + +// TestSearchActionExport verifies that Search with ActionExport writes the +// content to the export directory and that the exported file is readable. +func TestSearchActionExport(t *testing.T) { + dbPath := createTestDB(t) + b := newTestBackend(t, dbPath) + ctx := context.Background() + + _, err := b.Search(ctx, "Personal/Note", store.ActionExport, nil, nil) + if err != nil { + t.Fatalf("Search(ActionExport): %v", err) + } + + // The export dir is set to a temp dir by newTestBackend; find the exported file. + exportDir := b.cfg.ExportDir + entries, err := os.ReadDir(exportDir) + if err != nil { + t.Fatalf("ReadDir exportDir: %v", err) + } + if len(entries) == 0 { + t.Fatal("expected at least one exported file") + } + // Read the exported file and verify it contains the formatted content. + exported, err := os.ReadFile(filepath.Join(exportDir, entries[0].Name())) + if err != nil { + t.Fatalf("reading exported file: %v", err) + } + if !strings.Contains(string(exported), "Password: note123") { + t.Errorf("exported file missing 'Password: note123'; got: %q", exported) + } +} + +// TestSearchActionWithActionFn verifies that Search with a non-Cat action +// delegates to the provided actionFn (e.g., the paste path that extracts only +// the password). This exercises the applyAction default branch and confirms +// that only the password field is surfaced to the callback — matching the +// paste-only-password contract. +func TestSearchActionWithActionFn(t *testing.T) { + dbPath := createTestDB(t) + b := newTestBackend(t, dbPath) + ctx := context.Background() + + var captured []byte + // Simulate the paste actionFn: it should receive the full formatted content, + // from which the caller extracts the password. We verify the password field + // is present in the content passed to the callback. + pasteActionFn := func(_ context.Context, idx *store.Index, d *store.Data) error { + captured = d.Content + return nil + } + + _, err := b.Search(ctx, "Work/Email", store.ActionPaste, pasteActionFn, nil) + if err != nil { + t.Fatalf("Search(ActionPaste): %v", err) + } + if !strings.Contains(string(captured), "Password: secret") { + t.Errorf("content passed to paste actionFn missing password; got: %q", captured) + } + // Verify the password is extractable via parseContent (the actual paste path + // in the CLI would do this and send only the password to the clipboard). + pw, _, _, _ := parseContent(captured) + if pw != "secret" { + t.Errorf("parseContent from paste content: got password %q, want %q", pw, "secret") + } +} + +// TestShredAllExported verifies that ShredAllExported removes all regular files +// from the export directory and returns nil. +func TestShredAllExported(t *testing.T) { + dbPath := createTestDB(t) + b := newTestBackend(t, dbPath) + ctx := context.Background() + + // Export an entry to populate the export directory. + idx := &store.Index{Description: "Work/Email"} + d, err := b.LoadData(ctx, idx) + if err != nil { + t.Fatalf("LoadData: %v", err) + } + if err := d.Export(ctx, b.cfg.ExportDir, "email.txt"); err != nil { + t.Fatalf("Export: %v", err) + } + + // Verify the file was created. + if _, err := os.Stat(filepath.Join(b.cfg.ExportDir, "email.txt")); err != nil { + t.Fatalf("exported file missing: %v", err) + } + + // Shred exported files. + if err := b.ShredAllExported(ctx); err != nil { + t.Fatalf("ShredAllExported: %v", err) + } + + // Verify the file is gone. + if _, err := os.Stat(filepath.Join(b.cfg.ExportDir, "email.txt")); err == nil { + t.Error("exported file still present after ShredAllExported") + } +} + +// TestShredAllExported_missingDir verifies that ShredAllExported returns nil +// when the export directory does not exist (no export has been run yet). +func TestShredAllExported_missingDir(t *testing.T) { + dbPath := createTestDB(t) + b := newTestBackend(t, dbPath) + // Use a non-existent export dir path to trigger the IsNotExist branch. + b.cfg.ExportDir = filepath.Join(t.TempDir(), "nonexistent-export") + if err := b.ShredAllExported(context.Background()); err != nil { + t.Errorf("ShredAllExported with non-existent dir: %v", err) + } +} + +// TestImportRecursive verifies that ImportRecursive walks a directory tree and +// imports all files, preserving relative paths as descriptions. +func TestImportRecursive(t *testing.T) { + dbPath := createTestDB(t) + b := newTestBackend(t, dbPath) + ctx := context.Background() + + // Build a small directory tree with two files in different subdirectories. + srcDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(srcDir, "sub"), 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + files := map[string]string{ + "top.txt": "Password: toppass\n", + "sub/deep.txt": "Password: deeppass\n", + } + for rel, content := range files { + if err := os.WriteFile(filepath.Join(srcDir, rel), []byte(content), 0o600); err != nil { + t.Fatalf("write %s: %v", rel, err) + } + } + + if err := b.ImportRecursive(ctx, srcDir, "imported"); err != nil { + t.Fatalf("ImportRecursive: %v", err) + } + + // Re-open and verify both entries are present. + b2 := newTestBackend(t, dbPath) + var descs []string + if err := b2.WalkIndexes(ctx, "imported", func(idx *store.Index) error { + descs = append(descs, idx.Description) + return nil + }); err != nil { + t.Fatalf("WalkIndexes: %v", err) + } + + want := []string{"imported/top.txt", "imported/sub/deep.txt"} + for _, w := range want { + found := false + for _, d := range descs { + if d == w { + found = true + break + } + } + if !found { + t.Errorf("description %q not found after ImportRecursive; got: %v", w, descs) + } + } +} + +// TestParsePickerAction verifies that each known fzf key maps to the correct +// PickerAction and that unknown keys return (_, false). +func TestParsePickerAction(t *testing.T) { + cases := []struct { + key string + want store.PickerAction + wantOK bool + }{ + {"enter", store.PickerSelect, true}, + {"", store.PickerSelect, true}, + {"ctrl-t", store.PickerCat, true}, + {"alt-t", store.PickerCat, true}, + {"ctrl-y", store.PickerPaste, true}, + {"alt-y", store.PickerPaste, true}, + {"ctrl-o", store.PickerOpen, true}, + {"alt-o", store.PickerOpen, true}, + {"ctrl-e", store.PickerEdit, true}, + {"alt-e", store.PickerEdit, true}, + {"unknown-key", "", false}, + {"ctrl-z", "", false}, + } + + for _, tc := range cases { + t.Run(tc.key, func(t *testing.T) { + got, ok := parsePickerAction(tc.key) + if ok != tc.wantOK { + t.Errorf("parsePickerAction(%q) ok = %v; want %v", tc.key, ok, tc.wantOK) + } + if ok && got != tc.want { + t.Errorf("parsePickerAction(%q) = %q; want %q", tc.key, got, tc.want) + } + }) + } +} + +// TestBuildPickerEntries verifies that buildPickerEntries produces one entry +// per index with the correct Description, Kind, and non-empty HashSuffix for +// entries with a hash of sufficient length. +func TestBuildPickerEntries(t *testing.T) { + indexes := store.IndexSlice{ + {Description: "Work/Email", Hash: strings.Repeat("a", 64)}, + {Description: "images/logo.png", Hash: strings.Repeat("b", 64)}, + } + + entries := buildPickerEntries(indexes) + + if len(entries) != 2 { + t.Fatalf("buildPickerEntries len = %d; want 2", len(entries)) + } + + if entries[0].Description != "Work/Email" { + t.Errorf("entries[0].Description = %q; want Work/Email", entries[0].Description) + } + if entries[0].Kind != "TEXT" { + t.Errorf("entries[0].Kind = %q; want TEXT", entries[0].Kind) + } + if entries[0].HashSuffix == "" { + t.Error("entries[0].HashSuffix must be non-empty when hash length >= 63") + } + // RowID is 1-based, so the first entry must be 1. + if entries[0].RowID != 1 { + t.Errorf("entries[0].RowID = %d; want 1", entries[0].RowID) + } + + // "images/logo.png" has extension ".png" → IsBinary() returns true. + if entries[1].Kind != "BINARY" { + t.Errorf("entries[1].Kind = %q; want BINARY", entries[1].Kind) + } + // RowID for the second entry must be 2. + if entries[1].RowID != 2 { + t.Errorf("entries[1].RowID = %d; want 2", entries[1].RowID) + } +} + +// TestImportForce verifies that Import with force=true replaces an existing +// entry and reflects updated content in a subsequent LoadData call. +func TestImportForce(t *testing.T) { + dbPath := createTestDB(t) + b := newTestBackend(t, dbPath) + ctx := context.Background() + + srcFile := filepath.Join(t.TempDir(), "creds.txt") + newContent := string(formatContent("replaced", "replaced-user", "", "")) + if err := os.WriteFile(srcFile, []byte(newContent), 0o600); err != nil { + t.Fatalf("write src file: %v", err) + } + + // force=true must succeed even though "Work/Email" already exists. + if err := b.Import(ctx, srcFile, "Work/Email", true); err != nil { + t.Fatalf("Import force: %v", err) + } + + b2 := newTestBackend(t, dbPath) + idx := &store.Index{Description: "Work/Email"} + d, err := b2.LoadData(ctx, idx) + if err != nil { + t.Fatalf("LoadData after forced import: %v", err) + } + pw, _, _, _ := parseContent(d.Content) + if pw != "replaced" { + t.Errorf("password after forced import = %q; want %q", pw, "replaced") + } +} + +// TestSearchActionCatSkipsBinary verifies that actionCat does not print content +// for binary entries (it prints a "Not displaying" notice instead). +func TestSearchActionCatSkipsBinary(t *testing.T) { + dbPath := createTestDB(t) + b := newTestBackend(t, dbPath) + ctx := context.Background() + + var searchErr error + // "Work/Report/report.pdf" is a binary attachment — actionCat must skip it. + out := captureStdout(t, func() { + _, searchErr = b.Search(ctx, `^Work/Report/report\.pdf$`, store.ActionCat, nil, nil) + }) + + if searchErr != nil { + t.Fatalf("Search(ActionCat binary): %v", searchErr) + } + // The output must contain the "Not displaying" notice, not raw binary bytes. + if !strings.Contains(out, "Not displaying") { + t.Errorf("expected 'Not displaying' notice for binary entry; got: %q", out) + } +} + +// TestEnsureRootGroup_nilContent verifies that ensureRootGroup initialises +// Content and Root on a database that has no Content set. After the call the +// database must have at least one top-level group. +func TestEnsureRootGroup_nilContent(t *testing.T) { + // A bare &Database{} has nil Content; ensureRootGroup must fill it in. + db := &gokeepasslib.Database{} + ensureRootGroup(db) + if db.Content == nil { + t.Fatal("ensureRootGroup: Content is nil") + } + if db.Content.Root == nil { + t.Fatal("ensureRootGroup: Root is nil") + } + if len(db.Content.Root.Groups) == 0 { + t.Fatal("ensureRootGroup: no top-level groups after initialisation") + } +} + +// TestEnsureRootGroup_nilRoot verifies that ensureRootGroup creates a Root +// group when db.Content is present but db.Content.Root is nil. +func TestEnsureRootGroup_nilRoot(t *testing.T) { + db := gokeepasslib.NewDatabase() + db.Content.Root = nil // simulate missing root + ensureRootGroup(db) + if db.Content.Root == nil { + t.Fatal("ensureRootGroup: Root still nil after call") + } + if len(db.Content.Root.Groups) == 0 { + t.Fatal("ensureRootGroup: no top-level groups after root fix") + } +} + +// TestEnsureRootGroup_emptyGroups verifies that ensureRootGroup adds a "Root" +// group when db.Content.Root.Groups is empty. +func TestEnsureRootGroup_emptyGroups(t *testing.T) { + db := gokeepasslib.NewDatabase() + // Remove all groups so the len==0 branch fires. + db.Content.Root.Groups = nil + ensureRootGroup(db) + if len(db.Content.Root.Groups) == 0 { + t.Fatal("ensureRootGroup: no group added for empty Groups slice") + } + if db.Content.Root.Groups[0].Name != "Root" { + t.Errorf("ensureRootGroup: group name = %q; want Root", db.Content.Root.Groups[0].Name) + } +} + // TestFormatParseRoundtrip verifies that formatContent and parseContent are // mutual inverses across a range of inputs. func TestFormatParseRoundtrip(t *testing.T) { |
