Normalize vault storage root on open and create

This commit is contained in:
Joe Julian
2026-04-13 07:29:51 -07:00
parent 6790399e24
commit eccfb886ee
7 changed files with 252 additions and 56 deletions
-7
View File
@@ -6,13 +6,6 @@ These segments are intended to be independently executable wherever possible.
Each segment has its own local exit criteria.
The product is not complete until the global exit criteria at the end of this file are also met.
## Priority Bugs
- Vault root view bug:
ensure vault create/open maintains the physical `keepass` storage root,
lazily creates recycle-bin structure when needed, and warns when opening a
legacy vault that had to be normalized.
## UI Review Follow-Ups
These items came from a hands-on emulator and desktop walkthrough.
+17 -1
View File
@@ -101,6 +101,10 @@ type RemoteOpenableSession interface {
OpenRemote(webdav.Client, string, vault.MasterKey) error
}
type WarningSession interface {
ConsumeWarning() string
}
type SecurityConfigurableSession interface {
ConfigureSecurity(vault.SecuritySettings) error
SecuritySettings() vault.SecuritySettings
@@ -841,7 +845,13 @@ func (s *State) Unlock(key vault.MasterKey) error {
return fmt.Errorf("session is not lockable")
}
return session.Unlock(key)
if err := session.Unlock(key); err != nil {
return err
}
if warningSession, ok := s.Session.(WarningSession); ok {
s.StatusMessage = warningSession.ConsumeWarning()
}
return nil
}
func (s *State) ChangeMasterKey(key vault.MasterKey) error {
@@ -1003,6 +1013,9 @@ func (s *State) OpenVault(path string, key vault.MasterKey) error {
s.CurrentPath = nil
s.SelectedEntryID = ""
s.Dirty = false
if warningSession, ok := s.Session.(WarningSession); ok {
s.StatusMessage = warningSession.ConsumeWarning()
}
return nil
}
@@ -1033,6 +1046,9 @@ func (s *State) OpenRemoteVault(client webdav.Client, path string, key vault.Mas
s.CurrentPath = nil
s.SelectedEntryID = ""
s.Dirty = false
if warningSession, ok := s.Session.(WarningSession); ok {
s.StatusMessage = warningSession.ConsumeWarning()
}
return nil
}
+44 -11
View File
@@ -2441,8 +2441,8 @@ func TestUIOpenRemoteActionBootstrapsFromLocalVaultBinding(t *testing.T) {
if err != nil {
t.Fatalf("Session.Current() error = %v", err)
}
if got := current.EntriesInPath([]string{"Root", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" {
t.Fatalf("EntriesInPath(Root/Internet) = %#v, want Vault Console", got)
if got := current.EntriesInPath([]string{"keepass", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" {
t.Fatalf("EntriesInPath(keepass/Internet) = %#v, want Vault Console", got)
}
}
@@ -2675,8 +2675,8 @@ func TestUIStartOpenRemoteActionBootstrapsFromLocalVaultBinding(t *testing.T) {
if err != nil {
t.Fatalf("Session.Current() error = %v", err)
}
if got := current.EntriesInPath([]string{"Root", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" {
t.Fatalf("EntriesInPath(Root/Internet) = %#v, want Vault Console", got)
if got := current.EntriesInPath([]string{"keepass", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" {
t.Fatalf("EntriesInPath(keepass/Internet) = %#v, want Vault Console", got)
}
}
@@ -3180,8 +3180,8 @@ func TestUIAdvancedSynchronizeFromLocalMergesIntoCurrentVault(t *testing.T) {
if err != nil {
t.Fatalf("reopened Current() error = %v", err)
}
if got := len(model.EntriesInPath([]string{"Root", "Internet"})); got != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", got)
if got := len(model.EntriesInPath([]string{"keepass", "Internet"})); got != 2 {
t.Fatalf("len(EntriesInPath(keepass/Internet)) = %d, want 2", got)
}
}
@@ -3241,8 +3241,8 @@ func TestUIAdvancedSynchronizeFromImportedLocalVaultMergesIntoCurrentVault(t *te
if err != nil {
t.Fatalf("reopened Current() error = %v", err)
}
if got := len(model.EntriesInPath([]string{"Root", "Internet"})); got != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", got)
if got := len(model.EntriesInPath([]string{"keepass", "Internet"})); got != 2 {
t.Fatalf("len(EntriesInPath(keepass/Internet)) = %d, want 2", got)
}
}
@@ -3406,8 +3406,8 @@ func TestUIAdvancedSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
if err != nil {
t.Fatalf("reopened Current() error = %v", err)
}
if got := len(model.EntriesInPath([]string{"Root", "Internet"})); got != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", got)
if got := len(model.EntriesInPath([]string{"keepass", "Internet"})); got != 2 {
t.Fatalf("len(EntriesInPath(keepass/Internet)) = %d, want 2", got)
}
}
@@ -5136,6 +5136,39 @@ func TestUIAutoEntersSingleVaultRootGroupAndDisplaysSlashRoot(t *testing.T) {
}
}
func TestUIOpenVaultShowsLegacyRootNormalizationWarning(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "legacy-root.kdbx")
var encoded bytes.Buffer
if err := vault.SaveKDBX(&encoded, vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Crew", "Internet"}},
},
Groups: [][]string{
{"Root"},
{"Root", "Crew"},
{"Root", "Crew", "Internet"},
},
}, "correct horse battery staple"); err != nil {
t.Fatalf("SaveKDBX() error = %v", err)
}
if err := os.WriteFile(path, encoded.Bytes(), 0o600); err != nil {
t.Fatalf("WriteFile(legacy-root.kdbx) error = %v", err)
}
u := newUIWithSession("desktop", &session.Manager{})
u.masterPassword.SetText("correct horse battery staple")
u.vaultPath.SetText(path)
if err := u.openVaultAction(); err != nil {
t.Fatalf("openVaultAction() error = %v", err)
}
if got := u.state.StatusMessage; !strings.Contains(got, "legacy vault root") {
t.Fatalf("StatusMessage = %q, want legacy vault root normalization warning", got)
}
}
func TestUIAutoEntersSingleVaultRootWhenRecycleBinAlsoExists(t *testing.T) {
t.Parallel()
@@ -8480,7 +8513,7 @@ func TestUIConsumesPendingSharedVaultImportOnStartup(t *testing.T) {
if err := reopened.openVaultAction(); err != nil {
t.Fatalf("openVaultAction(imported) error = %v", err)
}
reopened.state.NavigateToPath([]string{"Crew", "Internet"})
reopened.state.NavigateToPath([]string{"Root", "Crew", "Internet"})
reopened.filter()
if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Bellagio"}) {
t.Fatalf("filteredTitles() = %v, want [Bellagio]", got)
+72 -25
View File
@@ -12,6 +12,7 @@ import (
"strings"
"git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/internal/vaultview"
"git.julianfamily.org/keepassgo/internal/webdav"
)
@@ -31,6 +32,7 @@ type Manager struct {
remoteClient *webdav.Client
remotePath string
remoteVersion webdav.Version
warning string
}
type PreparedLocalOpen struct {
@@ -40,6 +42,7 @@ type PreparedLocalOpen struct {
Key vault.MasterKey
Encoded []byte
VaultRoot string
Warning string
}
type PreparedRemoteOpen struct {
@@ -51,6 +54,7 @@ type PreparedRemoteOpen struct {
Encoded []byte
VaultRoot string
RemoteVersion webdav.Version
Warning string
}
type PreparedUnlock struct {
@@ -58,6 +62,7 @@ type PreparedUnlock struct {
Config *vault.KDBXConfig
Key vault.MasterKey
VaultRoot string
Warning string
}
func (m *Manager) SecuritySettings() vault.SecuritySettings {
@@ -74,7 +79,7 @@ func (m *Manager) ConfigureSecurity(settings vault.SecuritySettings) error {
}
func (m *Manager) Create(model vault.Model, key vault.MasterKey) error {
root := detectSingleVaultRoot(model)
root := vaultview.KeepassRoot
model = normalizeUnderRoot(model, root)
var encoded bytes.Buffer
if err := vault.SaveKDBXWithConfigAndKey(&encoded, model, key, m.config); err != nil {
@@ -86,6 +91,7 @@ func (m *Manager) Create(model vault.Model, key vault.MasterKey) error {
m.vaultRoot = root
m.encoded = encoded.Bytes()
m.locked = false
m.warning = ""
return nil
}
@@ -118,6 +124,12 @@ func (m *Manager) Open(path string, key vault.MasterKey) error {
return nil
}
func (m *Manager) ConsumeWarning() string {
warning := strings.TrimSpace(m.warning)
m.warning = ""
return warning
}
func (m *Manager) Save() error {
if m.remoteClient != nil && m.remotePath != "" {
return m.SaveRemote()
@@ -254,7 +266,7 @@ func (m *Manager) SaveAs(path string) error {
func (m *Manager) Replace(model vault.Model) {
root := m.vaultRoot
if root == "" {
root = detectSingleVaultRoot(model)
root = vaultview.KeepassRoot
}
m.model = normalizeUnderRoot(model, root)
m.vaultRoot = root
@@ -305,12 +317,13 @@ func PrepareLocalOpen(path string, key vault.MasterKey) (PreparedLocalOpen, erro
return PreparedLocalOpen{}, fmt.Errorf("open %s: %w", path, err)
}
return PreparedLocalOpen{
Model: model,
Model: normalizeUnderRoot(model, vaultview.KeepassRoot),
Config: config,
Path: path,
Key: key,
Encoded: content,
VaultRoot: detectSingleVaultRoot(model),
VaultRoot: vaultview.KeepassRoot,
Warning: normalizationWarning(model),
}, nil
}
@@ -324,14 +337,15 @@ func PrepareRemoteOpen(client webdav.Client, path string, key vault.MasterKey) (
return PreparedRemoteOpen{}, fmt.Errorf("decode remote %s: %w", path, err)
}
return PreparedRemoteOpen{
Model: model,
Model: normalizeUnderRoot(model, vaultview.KeepassRoot),
Config: config,
Client: client,
Path: path,
Key: key,
Encoded: content,
VaultRoot: detectSingleVaultRoot(model),
VaultRoot: vaultview.KeepassRoot,
RemoteVersion: version,
Warning: normalizationWarning(model),
}, nil
}
@@ -341,10 +355,11 @@ func PrepareUnlock(encoded []byte, key vault.MasterKey) (PreparedUnlock, error)
return PreparedUnlock{}, fmt.Errorf("unlock vault: %w", err)
}
return PreparedUnlock{
Model: model,
Model: normalizeUnderRoot(model, vaultview.KeepassRoot),
Config: config,
Key: key,
VaultRoot: detectSingleVaultRoot(model),
VaultRoot: vaultview.KeepassRoot,
Warning: normalizationWarning(model),
}, nil
}
@@ -359,6 +374,7 @@ func (m *Manager) ApplyPreparedLocalOpen(prepared PreparedLocalOpen) {
m.remoteClient = nil
m.remotePath = ""
m.remoteVersion = webdav.Version{}
m.warning = prepared.Warning
}
func (m *Manager) ApplyPreparedRemoteOpen(prepared PreparedRemoteOpen) {
@@ -372,6 +388,7 @@ func (m *Manager) ApplyPreparedRemoteOpen(prepared PreparedRemoteOpen) {
m.remotePath = prepared.Path
m.remoteVersion = prepared.RemoteVersion
m.path = ""
m.warning = prepared.Warning
}
func (m *Manager) ApplyPreparedUnlock(prepared PreparedUnlock) {
@@ -380,6 +397,7 @@ func (m *Manager) ApplyPreparedUnlock(prepared PreparedUnlock) {
m.key = prepared.Key
m.vaultRoot = prepared.VaultRoot
m.locked = false
m.warning = prepared.Warning
}
func (m *Manager) ChangeMasterKey(key vault.MasterKey) error {
@@ -584,9 +602,7 @@ func (m *Manager) reloadCurrentLocal(merged vault.Model) error {
return err
}
m.model = merged
if root := detectSingleVaultRoot(merged); root != "" {
m.vaultRoot = root
}
m.vaultRoot = vaultview.KeepassRoot
m.encoded = encoded
m.locked = false
return nil
@@ -603,9 +619,7 @@ func (m *Manager) reloadCurrentRemote(merged vault.Model) error {
return fmt.Errorf("reopen remote %s after synchronize: %w", m.remotePath, err)
}
m.model = merged
if root := detectSingleVaultRoot(merged); root != "" {
m.vaultRoot = root
}
m.vaultRoot = vaultview.KeepassRoot
m.encoded = encoded
m.remoteVersion = version
m.locked = false
@@ -867,17 +881,6 @@ func mergePeerGroups(primary, secondary [][]string) [][]string {
return out
}
func detectSingleVaultRoot(model vault.Model) string {
if len(model.EntriesInPath(nil)) != 0 {
return ""
}
groups := model.ChildGroups(nil)
if len(groups) != 1 {
return ""
}
return groups[0]
}
func normalizeUnderRoot(model vault.Model, root string) vault.Model {
if root == "" {
return model
@@ -888,8 +891,15 @@ func normalizeUnderRoot(model vault.Model, root string) vault.Model {
switch {
case len(path) == 0:
return []string{root}
case path[0] == "Root":
if len(path) == 1 {
return []string{root}
}
return append([]string{root}, path[1:]...)
case path[0] == root:
return path
case path[0] == "Templates":
return path
default:
return append([]string{root}, path...)
}
@@ -907,12 +917,49 @@ func normalizeUnderRoot(model vault.Model, root string) vault.Model {
out.RecycleBin[i].History[j].Path = normalizePath(out.RecycleBin[i].History[j].Path)
}
}
for i := range out.Templates {
out.Templates[i].Path = normalizePath(out.Templates[i].Path)
for j := range out.Templates[i].History {
out.Templates[i].History[j].Path = normalizePath(out.Templates[i].History[j].Path)
}
}
for i := range out.Groups {
out.Groups[i] = normalizePath(out.Groups[i])
}
return out
}
func normalizationWarning(model vault.Model) string {
if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 {
return ""
}
if usesKeepassStorageRoot(model) {
return ""
}
return "Opened legacy vault root layout and normalized it under keepass."
}
func usesKeepassStorageRoot(model vault.Model) bool {
if len(model.Entries) != 0 || len(model.RecycleBin) != 0 {
for _, entry := range model.Entries {
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
return true
}
}
for _, entry := range model.RecycleBin {
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
return true
}
}
}
for _, group := range model.Groups {
if len(group) > 0 && group[0] == vaultview.KeepassRoot {
return true
}
}
return false
}
func loadLocalSource(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
content, err := os.ReadFile(path)
if err != nil {
+63 -12
View File
@@ -64,7 +64,7 @@ func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) {
t.Fatalf("Current() after Unlock() error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 || got[0].Title != "Vault Console" || got[0].Password != "token-1" {
t.Fatalf("Current() entries = %#v, want persisted Vault Console entry", got)
}
@@ -110,12 +110,63 @@ func TestOpenLoadsExistingKDBXFromDisk(t *testing.T) {
t.Fatalf("Current() error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Home Assistant"})
got := current.EntriesInPath([]string{"keepass", "Home Assistant"})
if len(got) != 1 || got[0].Password != "token-2" {
t.Fatalf("Current() entries = %#v, want Home Assistant entry", got)
}
}
func TestOpenNormalizesLegacyVaultRootToKeepassAndReportsWarning(t *testing.T) {
t.Parallel()
key := vault.MasterKey{Password: "correct horse battery staple"}
model := vault.Model{
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Surveillance Console",
Username: "codex",
Password: "token-2",
URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"},
},
},
Groups: [][]string{
{"Root"},
{"Root", "Home Assistant"},
},
}
path := filepath.Join(t.TempDir(), "legacy-root.kdbx")
file, err := os.Create(path)
if err != nil {
t.Fatalf("Create(legacy path) error = %v", err)
}
if err := vault.SaveKDBXWithKey(file, model, key); err != nil {
file.Close()
t.Fatalf("SaveKDBXWithKey() error = %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("Close(legacy path) error = %v", err)
}
var sess Manager
if err := sess.Open(path, key); err != nil {
t.Fatalf("Open() error = %v", err)
}
current, err := sess.Current()
if err != nil {
t.Fatalf("Current() error = %v", err)
}
if got := current.EntriesInPath([]string{"keepass", "Home Assistant"}); len(got) != 1 || got[0].ID != "entry-1" {
t.Fatalf("Current().EntriesInPath([keepass Home Assistant]) = %#v, want normalized legacy entry", got)
}
if got := sess.ConsumeWarning(); got == "" {
t.Fatal("ConsumeWarning() = empty, want legacy root normalization warning")
}
}
func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
t.Parallel()
@@ -169,7 +220,7 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
t.Fatalf("LoadKDBXWithKey() error = %v", err)
}
got := loaded.EntriesInPath([]string{"Root", "Internet"})
got := loaded.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 || got[0].Password != "token-2" {
t.Fatalf("loaded entries = %#v, want updated password token-2", got)
}
@@ -307,7 +358,7 @@ func TestOpenRemoteLoadsExistingKDBXFromWebDAV(t *testing.T) {
t.Fatalf("Current() error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 || got[0].Password != "token-1" {
t.Fatalf("Current() entries = %#v, want Vault Console entry from remote vault", got)
}
@@ -392,7 +443,7 @@ func TestSaveRemotePersistsEditsBackToWebDAV(t *testing.T) {
t.Fatalf("LoadKDBXWithKey(savedBytes) error = %v", err)
}
got := loaded.EntriesInPath([]string{"Root", "Home Assistant"})
got := loaded.EntriesInPath([]string{"keepass", "Home Assistant"})
if len(got) != 1 || got[0].Password != "token-2" {
t.Fatalf("loaded remote entries = %#v, want updated token-2 entry", got)
}
@@ -513,7 +564,7 @@ func TestChangeMasterKeyReencryptsSavedAndLockedVault(t *testing.T) {
if err != nil {
t.Fatalf("Current() error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 || got[0].Title != "Vault Console" {
t.Fatalf("Current() entries = %#v, want Vault Console entry after ChangeMasterKey", got)
}
@@ -720,7 +771,7 @@ func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
t.Fatalf("Current() after reopen error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 {
t.Fatalf("len(EntriesInPath(Root/Internet)) after reopen = %d, want 1", len(got))
}
@@ -879,7 +930,7 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
t.Fatalf("reopened Current() error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got))
}
@@ -947,7 +998,7 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
t.Fatalf("reopened Current() error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
}
@@ -1004,7 +1055,7 @@ func TestSynchronizeFromLocalBytesMergesOtherVaultIntoCurrentSource(t *testing.T
t.Fatalf("reopened Current() error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
}
@@ -1063,7 +1114,7 @@ func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
t.Fatalf("reopened Current() error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
}
@@ -1148,7 +1199,7 @@ func TestSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
t.Fatalf("reopened Current() error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
}
+5
View File
@@ -26,6 +26,7 @@ var ErrInvalidMasterKey = errors.New("invalid master key")
const (
templatesRoot = "Templates"
recycleBinRoot = "Recycle Bin"
keepassRoot = "keepass"
keepassGOIDField = "KeePassGO-ID"
remoteProfilesKey = "keepassgo.remoteProfiles"
)
@@ -502,6 +503,10 @@ func compareGroupNames(a, b string) int {
return -1
case b == "Root":
return 1
case a == keepassRoot:
return -1
case b == keepassRoot:
return 1
case a == templatesRoot:
return -1
case b == templatesRoot:
+51
View File
@@ -755,6 +755,57 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
}
}
func TestKDBXKeepassRootEntriesPreserveAttachmentsWithTemplates(t *testing.T) {
t.Parallel()
model := Model{
Entries: []Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Password: "bellagio-pass-2",
URL: "https://vault.crew.example.invalid",
Path: []string{"keepass", "Internet"},
Attachments: map[string][]byte{
"token.txt": []byte("secret attachment contents"),
},
},
},
Templates: []Entry{
{
ID: "tpl-1",
Title: "Website Login",
Username: "template-user",
Password: "template-password",
Path: []string{"Templates", "Web"},
},
},
Groups: [][]string{
{"keepass", "Internet"},
{"Templates", "Web"},
},
}
var encoded bytes.Buffer
if err := SaveKDBX(&encoded, model, "correct horse battery staple"); err != nil {
t.Fatalf("SaveKDBX() error = %v", err)
}
loaded, err := LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple")
if err != nil {
t.Fatalf("LoadKDBX() error = %v", err)
}
got := loaded.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 {
t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got))
}
if string(got[0].Attachments["token.txt"]) != "secret attachment contents" {
t.Fatalf("attachment contents = %q, want %q", string(got[0].Attachments["token.txt"]), "secret attachment contents")
}
}
func mustGroup(name string, children ...any) gokeepasslib.Group {
group := gokeepasslib.NewGroup()
group.Name = name