summaryrefslogtreecommitdiff
path: root/internal/keepass/keepass_test.go
blob: 8db2ae4d3d2d6cc858af9cf5640decf472d79e30 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
package keepass

import (
	"context"
	"os"
	"path/filepath"
	"strings"
	"testing"

	gokeepasslib "github.com/tobischo/gokeepasslib/v3"

	"codeberg.org/snonux/foostore/internal/config"
	"codeberg.org/snonux/foostore/internal/store"
)

// createTestDB builds a minimal in-memory KeePass database, writes it to a
// temp file, and returns the file path. The caller must remove the file when
// done. The database uses password "testpass" with:
//   - Root/Work/Email  — text entry (password=secret, user=alice, url=https://mail.example.com)
//   - Root/Personal/Note — text entry (password=note123)
//   - Root/Work/Report — binary attachment named "report.pdf"
func createTestDB(t *testing.T) string {
	t.Helper()

	db := gokeepasslib.NewDatabase()
	db.Credentials = gokeepasslib.NewPasswordCredentials("testpass")

	root := gokeepasslib.NewGroup()
	root.Name = "Root"

	work := gokeepasslib.NewGroup()
	work.Name = "Work"

	personal := gokeepasslib.NewGroup()
	personal.Name = "Personal"

	// Email entry
	email := gokeepasslib.NewEntry()
	SetEntryField(&email, "Title", "Email")
	SetEntryField(&email, "Password", "secret")
	SetEntryField(&email, "UserName", "alice")
	SetEntryField(&email, "URL", "https://mail.example.com")
	SetEntryField(&email, "Notes", "Work email account")
	work.Entries = append(work.Entries, email)

	// Binary attachment on a separate entry
	report := gokeepasslib.NewEntry()
	SetEntryField(&report, "Title", "Report")
	binContent := []byte("PDF content here")
	bin := db.AddBinary(binContent)
	report.Binaries = append(report.Binaries, bin.CreateReference("report.pdf"))
	work.Entries = append(work.Entries, report)

	// Personal note entry
	note := gokeepasslib.NewEntry()
	SetEntryField(&note, "Title", "Note")
	SetEntryField(&note, "Password", "note123")
	personal.Entries = append(personal.Entries, note)

	root.Groups = append(root.Groups, work, personal)
	db.Content.Root.Groups = []gokeepasslib.Group{root}

	// Write to temp file
	tmp, err := os.CreateTemp(t.TempDir(), "test-*.kdbx")
	if err != nil {
		t.Fatalf("creating temp kdbx: %v", err)
	}
	defer tmp.Close()

	if err := gokeepasslib.NewEncoder(tmp).Encode(db); err != nil {
		t.Fatalf("encoding test db: %v", err)
	}
	return tmp.Name()
}

// newTestBackend opens the test database and returns a *Backend ready for use.
func newTestBackend(t *testing.T, dbPath string) *Backend {
	t.Helper()
	exportDir := filepath.Join(t.TempDir(), "export")
	cfg := &config.Config{
		KDBXPath:  dbPath,
		ExportDir: exportDir,
	}
	b, err := New(cfg, "testpass", nil)
	if err != nil {
		t.Fatalf("New: %v", err)
	}
	return b
}

// TestWalkIndexes verifies that all expected descriptions appear during a walk.
func TestWalkIndexes(t *testing.T) {
	dbPath := createTestDB(t)
	b := newTestBackend(t, dbPath)
	ctx := context.Background()

	tests := []struct {
		name       string
		searchTerm string
		wantDescs  []string
		wantCount  int
	}{
		{
			name:       "empty search returns all entries",
			searchTerm: "",
			wantDescs:  []string{"Work/Email", "Work/Report", "Work/Report/report.pdf", "Personal/Note"},
			wantCount:  4,
		},
		{
			name:       "search by group name",
			searchTerm: "Work",
			wantDescs:  []string{"Work/Email", "Work/Report", "Work/Report/report.pdf"},
			wantCount:  3,
		},
		{
			name:       "search by entry title",
			searchTerm: "Email",
			wantDescs:  []string{"Work/Email"},
			wantCount:  1,
		},
		{
			name:       "search by attachment name",
			searchTerm: "report.pdf",
			wantDescs:  []string{"Work/Report/report.pdf"},
			wantCount:  1,
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			var got []string
			err := b.WalkIndexes(ctx, tc.searchTerm, func(idx *store.Index) error {
				got = append(got, idx.Description)
				return nil
			})
			if err != nil {
				t.Fatalf("WalkIndexes error: %v", err)
			}
			if len(got) != tc.wantCount {
				t.Errorf("got %d entries, want %d; entries: %v", len(got), tc.wantCount, got)
			}
			for _, want := range tc.wantDescs {
				found := false
				for _, g := range got {
					if g == want {
						found = true
						break
					}
				}
				if !found {
					t.Errorf("description %q not found in walk results %v", want, got)
				}
			}
		})
	}
}

// TestLoadData verifies content formatting for text and binary entries.
func TestLoadData(t *testing.T) {
	dbPath := createTestDB(t)
	b := newTestBackend(t, dbPath)
	ctx := context.Background()

	tests := []struct {
		name        string
		description string
		wantContain []string
		wantBinary  bool
	}{
		{
			name:        "text entry content",
			description: "Work/Email",
			wantContain: []string{"Password: secret", "User: alice", "URL: https://mail.example.com", "Notes:"},
		},
		{
			name:        "binary attachment content",
			description: "Work/Report/report.pdf",
			wantContain: []string{"PDF content here"},
			wantBinary:  true,
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			// Build a synthetic index (same as WalkIndexes would produce).
			idx := &store.Index{Description: tc.description}
			d, err := b.LoadData(ctx, idx)
			if err != nil {
				t.Fatalf("LoadData error: %v", err)
			}
			content := string(d.Content)
			for _, want := range tc.wantContain {
				if !strings.Contains(content, want) {
					t.Errorf("content %q does not contain %q", content, want)
				}
			}
		})
	}
}

// TestSearch verifies that Search returns the correct sorted list and calls
// onMatch for each entry.
func TestSearch(t *testing.T) {
	dbPath := createTestDB(t)
	b := newTestBackend(t, dbPath)
	ctx := context.Background()

	var matched []string
	indexes, err := b.Search(ctx, "Work", store.ActionNone, nil, func(idx *store.Index) {
		matched = append(matched, idx.Description)
	})
	if err != nil {
		t.Fatalf("Search error: %v", err)
	}
	if len(indexes) == 0 {
		t.Fatal("expected at least one result from Search")
	}
	if len(matched) != len(indexes) {
		t.Errorf("onMatch called %d times, got %d indexes", len(matched), len(indexes))
	}
	// Verify sorted order.
	for i := 1; i < len(indexes); i++ {
		if indexes[i].Description < indexes[i-1].Description {
			t.Errorf("indexes not sorted: %v >= %v", indexes[i-1].Description, indexes[i].Description)
		}
	}
}

// TestAddAndWalk verifies that Add persists a new entry that appears in a
// subsequent WalkIndexes call.
func TestAddAndWalk(t *testing.T) {
	dbPath := createTestDB(t)
	b := newTestBackend(t, dbPath)
	ctx := context.Background()

	data := string(formatContent("pw123", "bob", "https://example.com", "some notes"))
	if err := b.Add(ctx, "Work/NewEntry", data); err != nil {
		t.Fatalf("Add error: %v", err)
	}

	// Re-open to verify persistence.
	b2 := newTestBackend(t, dbPath)
	found := false
	if err := b2.WalkIndexes(ctx, "NewEntry", func(idx *store.Index) error {
		if idx.Description == "Work/NewEntry" {
			found = true
		}
		return nil
	}); err != nil {
		t.Fatalf("WalkIndexes after Add: %v", err)
	}
	if !found {
		t.Error("Work/NewEntry not found after Add + re-open")
	}
}

// TestRemove verifies that Remove deletes an existing entry and it no longer
// appears in a subsequent WalkIndexes call.
func TestRemove(t *testing.T) {
	dbPath := createTestDB(t)
	b := newTestBackend(t, dbPath)
	ctx := context.Background()

	// Confirm deletion automatically.
	input := strings.NewReader("y\n")
	if err := b.Remove(ctx, "^Personal/Note$", input); err != nil {
		t.Fatalf("Remove error: %v", err)
	}

	// Re-open to verify the entry is gone.
	b2 := newTestBackend(t, dbPath)
	var found bool
	if err := b2.WalkIndexes(ctx, "Personal/Note", func(idx *store.Index) error {
		if idx.Description == "Personal/Note" {
			found = true
		}
		return nil
	}); err != nil {
		t.Fatalf("WalkIndexes after Remove: %v", err)
	}
	if found {
		t.Error("Personal/Note still present after Remove")
	}
}

// TestImportSkipsOnDuplicate verifies that Import with force=false returns nil
// and leaves the entry count unchanged when the destination already exists.
func TestImportSkipsOnDuplicate(t *testing.T) {
	dbPath := createTestDB(t)
	b := newTestBackend(t, dbPath)
	ctx := context.Background()

	// Create a temp file to use as import source.
	srcFile := filepath.Join(t.TempDir(), "creds.txt")
	if err := os.WriteFile(srcFile, []byte("Password: newpw\n"), 0o600); err != nil {
		t.Fatalf("writing src file: %v", err)
	}

	// Count baseline entries before any import.
	countEntries := func(b *Backend) int {
		var n int
		_ = b.WalkIndexes(ctx, "", func(*store.Index) error { n++; return nil })
		return n
	}
	before := countEntries(b)

	// Import with force=false — should skip silently because "Work/Email" already exists.
	if err := b.Import(ctx, srcFile, "Work/Email", false); err != nil {
		t.Fatalf("first Import error: %v", err)
	}

	// "Work/Email" already exists in the test DB — count must not have changed.
	after := countEntries(b)
	if after != before {
		t.Errorf("entry count changed: before=%d after=%d (expected no change)", before, after)
	}
}

// TestAddAttachment verifies that Add with a virtual attachment path creates
// an attachment on the parent entry and it surfaces via WalkIndexes and LoadData.
func TestAddAttachment(t *testing.T) {
	dbPath := createTestDB(t)
	b := newTestBackend(t, dbPath)
	ctx := context.Background()

	// "Work/Email" already exists; add an attachment to it.
	attachContent := []byte("attachment binary content")
	if err := b.Add(ctx, "Work/Email/notes.txt", string(attachContent)); err != nil {
		// notes.txt is a text extension — IsBinary() would return false,
		// but isAttachmentPath checks entry existence, not extension.
		// The parent "Work/Email" exists so this must succeed.
		t.Fatalf("Add attachment error: %v", err)
	}

	// Re-open and verify the attachment virtual entry appears.
	b2 := newTestBackend(t, dbPath)
	found := false
	if err := b2.WalkIndexes(ctx, "Work/Email/notes.txt", func(idx *store.Index) error {
		if idx.Description == "Work/Email/notes.txt" {
			found = true
		}
		return nil
	}); err != nil {
		t.Fatalf("WalkIndexes after AddAttachment: %v", err)
	}
	if !found {
		t.Error("Work/Email/notes.txt not found after Add attachment")
	}

	// LoadData for the attachment virtual entry must return the raw bytes.
	idx := &store.Index{Description: "Work/Email/notes.txt"}
	d, err := b2.LoadData(ctx, idx)
	if err != nil {
		t.Fatalf("LoadData attachment error: %v", err)
	}
	if string(d.Content) != string(attachContent) {
		t.Errorf("attachment content: got %q, want %q", d.Content, attachContent)
	}
}

// TestAddAttachmentReplace verifies that adding an attachment with an existing
// name replaces the old attachment bytes.
func TestAddAttachmentReplace(t *testing.T) {
	dbPath := createTestDB(t)
	b := newTestBackend(t, dbPath)
	ctx := context.Background()

	// "Work/Report" already exists and has "report.pdf" attached.
	// Replace it with new content.
	newContent := []byte("updated PDF bytes")
	if err := b.Add(ctx, "Work/Report/report.pdf", string(newContent)); err != nil {
		t.Fatalf("Add (replace) attachment error: %v", err)
	}

	// LoadData must return the new bytes.
	b2 := newTestBackend(t, dbPath)
	idx := &store.Index{Description: "Work/Report/report.pdf"}
	d, err := b2.LoadData(ctx, idx)
	if err != nil {
		t.Fatalf("LoadData after replace error: %v", err)
	}
	if string(d.Content) != string(newContent) {
		t.Errorf("attachment content after replace: got %q, want %q", d.Content, newContent)
	}
}

// TestAddNoParentCreatesTextEntry verifies that Add with a multi-component path
// whose parent does not exist as an entry creates a new regular text entry rather
// than treating the last component as an attachment filename. This is the
// "new nested entry" case where no parent entry has been established yet.
func TestAddNoParentCreatesTextEntry(t *testing.T) {
	dbPath := createTestDB(t)
	b := newTestBackend(t, dbPath)
	ctx := context.Background()

	// "Work/Ghost" does not exist as an entry, so "Work/Ghost/notes.txt" is
	// treated as a new text entry (not an attachment).
	if err := b.Add(ctx, "Work/Ghost/notes.txt", "Password: pw\n"); err != nil {
		t.Fatalf("Add new text entry error: %v", err)
	}

	// Re-open: the new entry must appear as a text entry (not an attachment).
	b2 := newTestBackend(t, dbPath)
	found := false
	if err := b2.WalkIndexes(ctx, "Work/Ghost/notes.txt", func(idx *store.Index) error {
		if idx.Description == "Work/Ghost/notes.txt" {
			found = true
		}
		return nil
	}); err != nil {
		t.Fatalf("WalkIndexes: %v", err)
	}
	if !found {
		t.Error("Work/Ghost/notes.txt not found after Add")
	}
}

// TestRemoveAttachment verifies that Remove on a virtual attachment path removes
// only the attachment and leaves the parent entry intact.
func TestRemoveAttachment(t *testing.T) {
	dbPath := createTestDB(t)
	b := newTestBackend(t, dbPath)
	ctx := context.Background()

	// "Work/Report/report.pdf" is a virtual attachment entry.
	input := strings.NewReader("y\n")
	if err := b.Remove(ctx, `^Work/Report/report\.pdf$`, input); err != nil {
		t.Fatalf("Remove attachment error: %v", err)
	}

	// Re-open: the parent entry "Work/Report" must still exist.
	b2 := newTestBackend(t, dbPath)
	parentFound := false
	attachFound := false
	if err := b2.WalkIndexes(ctx, "", func(idx *store.Index) error {
		switch idx.Description {
		case "Work/Report":
			parentFound = true
		case "Work/Report/report.pdf":
			attachFound = true
		}
		return nil
	}); err != nil {
		t.Fatalf("WalkIndexes after Remove attachment: %v", err)
	}
	if !parentFound {
		t.Error("parent entry Work/Report missing after attachment removal")
	}
	if attachFound {
		t.Error("Work/Report/report.pdf still present after Remove attachment")
	}
}

// TestAddThenRemoveAttachment verifies the full attachment lifecycle: Add,
// then Remove via WalkIndexes → LoadData roundtrip.
func TestAddThenRemoveAttachment(t *testing.T) {
	dbPath := createTestDB(t)
	b := newTestBackend(t, dbPath)
	ctx := context.Background()

	// Add an attachment to an existing entry.
	if err := b.Add(ctx, "Personal/Note/secret.bin", "binary payload"); err != nil {
		t.Fatalf("Add attachment error: %v", err)
	}

	// Verify it's there.
	b2 := newTestBackend(t, dbPath)
	idx := &store.Index{Description: "Personal/Note/secret.bin"}
	if _, err := b2.LoadData(ctx, idx); err != nil {
		t.Fatalf("LoadData after Add: %v", err)
	}

	// Remove it.
	input := strings.NewReader("y\n")
	if err := b2.Remove(ctx, `^Personal/Note/secret\.bin$`, input); err != nil {
		t.Fatalf("Remove attachment error: %v", err)
	}

	// Verify it's gone but parent remains.
	b3 := newTestBackend(t, dbPath)
	var descs []string
	if err := b3.WalkIndexes(ctx, "Personal", func(idx *store.Index) error {
		descs = append(descs, idx.Description)
		return nil
	}); err != nil {
		t.Fatalf("WalkIndexes: %v", err)
	}
	for _, d := range descs {
		if d == "Personal/Note/secret.bin" {
			t.Error("attachment still present after Remove")
		}
	}
}

// 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).
	// parseContent accepts string; convert captured []byte once here.
	pw, _, _, _ := parseContent(string(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)
	}
	// parseContent accepts string; d.Content is []byte so convert once.
	pw, _, _, _ := parseContent(string(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) {
	tests := []struct {
		name     string
		password string
		user     string
		url      string
		notes    string
	}{
		{
			name:     "full fields",
			password: "s3cr3t!",
			user:     "alice@example.com",
			url:      "https://example.com/login",
			notes:    "Two-factor auth enabled\nBackup codes in vault",
		},
		{name: "empty fields"},
		{
			name:     "only password",
			password: "pw",
		},
		{
			name:  "multiline notes",
			notes: "line1\nline2\nline3",
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			content := formatContent(tc.password, tc.user, tc.url, tc.notes)
			// parseContent accepts string; formatContent returns []byte, so convert once.
			gotPw, gotUser, gotURL, gotNotes := parseContent(string(content))
			if gotPw != tc.password {
				t.Errorf("password: got %q, want %q", gotPw, tc.password)
			}
			if gotUser != tc.user {