diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-17 08:45:13 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-17 08:45:13 +0300 |
| commit | 9e2bf4af8b7b3b4ca2980aa6285482e7db0cd151 (patch) | |
| tree | 96bcd8ad2a64e6dfc9d3bedc9d063eeef1591cf0 /internal/keepass/keepass_test.go | |
| parent | b53e348d89046ea8de5b82c283fb56980b53cdd8 (diff) | |
feat: add internal/keepass package with full Backend implementation (tasks k4+l4+m4)
Read-write KeePass backend implementing backend.Backend:
- WalkIndexes flattens groups into 'Group/Title' virtual entries
- LoadData returns formatted Password/User/URL/Notes for text entries
or raw bytes for binary attachments
- WriteBack hook enables edit round-trip through the kdbx file
- Add/Import/ImportRecursive/Remove with atomic tmp+rename save
- Binary attachments surface as virtual 'Group/Title/filename' entries;
Add to such a path creates/replaces the attachment on the parent entry
- AtomicSave exported for reuse by cli/kdbx_store.go
- parseContent is the tolerant inverse of formatContent
- Helpers EnsureGroup, UpsertEntryByTitle, SetEntryField, SplitDescriptionPath,
SanitizeRelativePath exported so cli/kdbx_store.go avoids duplication
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/keepass/keepass_test.go')
| -rw-r--r-- | internal/keepass/keepass_test.go | 176 |
1 files changed, 176 insertions, 0 deletions
diff --git a/internal/keepass/keepass_test.go b/internal/keepass/keepass_test.go index 6adb084..dcd8712 100644 --- a/internal/keepass/keepass_test.go +++ b/internal/keepass/keepass_test.go @@ -316,6 +316,182 @@ func TestImportSkipsOnDuplicate(t *testing.T) { } } +// 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") + } + } +} + // TestFormatParseRoundtrip verifies that formatContent and parseContent are // mutual inverses across a range of inputs. func TestFormatParseRoundtrip(t *testing.T) { |
