Add regression coverage for KDBX reopen cycles
This commit is contained in:
@@ -537,3 +537,132 @@ func TestSavePreservesOpenedKDBXSecuritySettings(t *testing.T) {
|
|||||||
t.Fatalf("saved KDF UUID = %x, want %x", reloaded.Header.FileHeaders.KdfParameters.UUID, db.Header.FileHeaders.KdfParameters.UUID)
|
t.Fatalf("saved KDF UUID = %x, want %x", reloaded.Header.FileHeaders.KdfParameters.UUID, db.Header.FileHeaders.KdfParameters.UUID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
key := vault.MasterKey{Password: "correct horse battery staple"}
|
||||||
|
model := vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "entry-1",
|
||||||
|
Title: "Vault Console",
|
||||||
|
Username: "dannyocean",
|
||||||
|
Password: "token-2",
|
||||||
|
URL: "https://vault.crew.example.invalid",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
Attachments: map[string][]byte{
|
||||||
|
"token.txt": []byte("secret attachment contents"),
|
||||||
|
},
|
||||||
|
History: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "entry-1-history-1",
|
||||||
|
Title: "Vault Console",
|
||||||
|
Username: "dannyocean",
|
||||||
|
Password: "token-1",
|
||||||
|
URL: "https://vault.crew.example.invalid",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Templates: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "tpl-1",
|
||||||
|
Title: "Website Login",
|
||||||
|
Username: "template-user",
|
||||||
|
Password: "template-password",
|
||||||
|
Path: []string{"Templates", "Web"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
RecycleBin: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "deleted-1",
|
||||||
|
Title: "Retired Entry",
|
||||||
|
Username: "archived-user",
|
||||||
|
Password: "retired-token",
|
||||||
|
Path: []string{"Root", "Archive"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Groups: [][]string{
|
||||||
|
{"Root", "Archive"},
|
||||||
|
{"Root", "Empty Group"},
|
||||||
|
{"Templates", "Web"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var remoteBytes bytes.Buffer
|
||||||
|
if err := vault.SaveKDBXWithKey(&remoteBytes, model, key); err != nil {
|
||||||
|
t.Fatalf("SaveKDBXWithKey(seed remote) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
etag := "\"v1\""
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
w.Header().Set("ETag", etag)
|
||||||
|
_, _ = w.Write(remoteBytes.Bytes())
|
||||||
|
case http.MethodPut:
|
||||||
|
if got := r.Header.Get("If-Match"); got != etag {
|
||||||
|
t.Fatalf("If-Match header = %q, want %q", got, etag)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadAll(PUT body) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
remoteBytes.Reset()
|
||||||
|
if _, err := remoteBytes.Write(payload); err != nil {
|
||||||
|
t.Fatalf("Write(remoteBytes) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
etag = "\"v2\""
|
||||||
|
w.Header().Set("ETag", etag)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected method %s", r.Method)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := webdav.Client{BaseURL: server.URL}
|
||||||
|
|
||||||
|
var sess Manager
|
||||||
|
if err := sess.OpenRemote(client, "vaults/main.kdbx", key); err != nil {
|
||||||
|
t.Fatalf("OpenRemote() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := sess.SaveRemote(); err != nil {
|
||||||
|
t.Fatalf("SaveRemote() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var reopened Manager
|
||||||
|
if err := reopened.OpenRemote(client, "vaults/main.kdbx", key); err != nil {
|
||||||
|
t.Fatalf("reopen OpenRemote() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
current, err := reopened.Current()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Current() after reopen error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := current.EntriesInPath([]string{"Root", "Internet"})
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("len(EntriesInPath(Root/Internet)) after reopen = %d, want 1", len(got))
|
||||||
|
}
|
||||||
|
if got[0].ID != "entry-1" {
|
||||||
|
t.Fatalf("entry ID after remote reopen = %q, want %q", got[0].ID, "entry-1")
|
||||||
|
}
|
||||||
|
if len(got[0].History) != 1 || got[0].History[0].ID != "entry-1-history-1" {
|
||||||
|
t.Fatalf("History after remote reopen = %#v, want stable history ID entry-1-history-1", got[0].History)
|
||||||
|
}
|
||||||
|
if string(got[0].Attachments["token.txt"]) != "secret attachment contents" {
|
||||||
|
t.Fatalf("attachment after remote reopen = %q, want %q", string(got[0].Attachments["token.txt"]), "secret attachment contents")
|
||||||
|
}
|
||||||
|
if len(current.Templates) != 1 || current.Templates[0].Path[1] != "Web" {
|
||||||
|
t.Fatalf("Templates after remote reopen = %#v, want Website Login in Templates/Web", current.Templates)
|
||||||
|
}
|
||||||
|
if len(current.RecycleBin) != 1 || current.RecycleBin[0].Path[1] != "Archive" {
|
||||||
|
t.Fatalf("RecycleBin after remote reopen = %#v, want retired entry in Root/Archive", current.RecycleBin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+92
-34
@@ -23,8 +23,8 @@ type KDBXConfig struct {
|
|||||||
var ErrInvalidMasterKey = errors.New("invalid master key")
|
var ErrInvalidMasterKey = errors.New("invalid master key")
|
||||||
|
|
||||||
const (
|
const (
|
||||||
templatesRoot = "Templates"
|
templatesRoot = "Templates"
|
||||||
recycleBinRoot = "Recycle Bin"
|
recycleBinRoot = "Recycle Bin"
|
||||||
keepassGOIDField = "KeePassGO-ID"
|
keepassGOIDField = "KeePassGO-ID"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -46,33 +46,29 @@ func SaveKDBXWithConfigAndKey(wr io.Writer, model Model, key MasterKey, config *
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
header := gokeepasslib.NewHeader()
|
db := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4())
|
||||||
|
db.Credentials = credentials
|
||||||
|
db.Content.Meta = gokeepasslib.NewMetaData()
|
||||||
|
db.Content.Root = &gokeepasslib.RootData{}
|
||||||
if config != nil && config.Header != nil {
|
if config != nil && config.Header != nil {
|
||||||
header = cloneHeader(config.Header)
|
db.Header = cloneHeader(config.Header)
|
||||||
|
db.Hashes = gokeepasslib.NewHashes(db.Header)
|
||||||
}
|
}
|
||||||
content := &gokeepasslib.DBContent{
|
if db.Header.IsKdbx4() {
|
||||||
Meta: gokeepasslib.NewMetaData(),
|
|
||||||
Root: &gokeepasslib.RootData{},
|
|
||||||
}
|
|
||||||
if header.IsKdbx4() {
|
|
||||||
if config != nil && config.InnerHeader != nil {
|
if config != nil && config.InnerHeader != nil {
|
||||||
content.InnerHeader = cloneInnerHeader(config.InnerHeader)
|
db.Content.InnerHeader = cloneInnerHeader(config.InnerHeader)
|
||||||
} else {
|
db.Content.InnerHeader.Binaries = nil
|
||||||
content.InnerHeader = &gokeepasslib.InnerHeader{
|
} else if db.Content.InnerHeader == nil {
|
||||||
|
db.Content.InnerHeader = &gokeepasslib.InnerHeader{
|
||||||
InnerRandomStreamID: gokeepasslib.ChaChaStreamID,
|
InnerRandomStreamID: gokeepasslib.ChaChaStreamID,
|
||||||
InnerRandomStreamKey: randomBytes(64),
|
InnerRandomStreamKey: randomBytes(64),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
db.Content.InnerHeader = nil
|
||||||
}
|
}
|
||||||
|
db.Content.Root.Groups = buildGroupTree(db, model)
|
||||||
db := &gokeepasslib.Database{
|
db.Content.Root.DeletedObjects = nil
|
||||||
Header: header,
|
|
||||||
Credentials: credentials,
|
|
||||||
Content: content,
|
|
||||||
Hashes: gokeepasslib.NewHashes(header),
|
|
||||||
}
|
|
||||||
db.Content.Root.Groups = buildGroupTree(db, entriesForPersistence(model))
|
|
||||||
db.Content.Root.DeletedObjects = marshalDeletedObjects(model.RecycleBin)
|
|
||||||
|
|
||||||
if err := db.LockProtectedEntries(); err != nil {
|
if err := db.LockProtectedEntries(); err != nil {
|
||||||
return fmt.Errorf("lock protected entries: %w", err)
|
return fmt.Errorf("lock protected entries: %w", err)
|
||||||
@@ -87,20 +83,21 @@ func SaveKDBXWithConfigAndKey(wr io.Writer, model Model, key MasterKey, config *
|
|||||||
|
|
||||||
func appendGroupEntries(model *Model, db *gokeepasslib.Database, group gokeepasslib.Group, path []string) {
|
func appendGroupEntries(model *Model, db *gokeepasslib.Database, group gokeepasslib.Group, path []string) {
|
||||||
path = append(clonePath(path), group.Name)
|
path = append(clonePath(path), group.Name)
|
||||||
|
model.CreateGroup(path[:len(path)-1], group.Name)
|
||||||
|
|
||||||
for _, entry := range group.Entries {
|
for _, entry := range group.Entries {
|
||||||
appendModelEntry(model, Entry{
|
appendModelEntry(model, Entry{
|
||||||
ID: extractEntryID(entry),
|
ID: extractEntryID(entry),
|
||||||
Title: entry.GetTitle(),
|
Title: entry.GetTitle(),
|
||||||
Username: entry.GetContent("UserName"),
|
Username: entry.GetContent("UserName"),
|
||||||
Password: entry.GetPassword(),
|
Password: entry.GetPassword(),
|
||||||
URL: entry.GetContent("URL"),
|
URL: entry.GetContent("URL"),
|
||||||
Notes: entry.GetContent("Notes"),
|
Notes: entry.GetContent("Notes"),
|
||||||
Tags: splitTags(entry.Tags),
|
Tags: splitTags(entry.Tags),
|
||||||
Fields: extractCustomFields(entry),
|
Fields: extractCustomFields(entry),
|
||||||
Attachments: extractAttachments(db, entry),
|
Attachments: extractAttachments(db, entry),
|
||||||
History: extractHistory(db, entry, path),
|
History: extractHistory(db, entry, path),
|
||||||
Path: clonePath(path),
|
Path: clonePath(path),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +204,7 @@ func extractHistory(db *gokeepasslib.Database, entry gokeepasslib.Entry, path []
|
|||||||
for _, item := range entry.Histories {
|
for _, item := range entry.Histories {
|
||||||
for _, historical := range item.Entries {
|
for _, historical := range item.Entries {
|
||||||
history = append(history, Entry{
|
history = append(history, Entry{
|
||||||
ID: marshalUUID(historical.UUID),
|
ID: extractEntryID(historical),
|
||||||
Title: historical.GetTitle(),
|
Title: historical.GetTitle(),
|
||||||
Username: historical.GetContent("UserName"),
|
Username: historical.GetContent("UserName"),
|
||||||
Password: historical.GetPassword(),
|
Password: historical.GetPassword(),
|
||||||
@@ -235,7 +232,8 @@ type MasterKey struct {
|
|||||||
KeyFileData []byte
|
KeyFileData []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildGroupTree(db *gokeepasslib.Database, entries []Entry) []gokeepasslib.Group {
|
func buildGroupTree(db *gokeepasslib.Database, model Model) []gokeepasslib.Group {
|
||||||
|
entries := entriesForPersistence(model)
|
||||||
root := &groupNode{children: map[string]*groupNode{}}
|
root := &groupNode{children: map[string]*groupNode{}}
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
node := root
|
node := root
|
||||||
@@ -250,6 +248,18 @@ func buildGroupTree(db *gokeepasslib.Database, entries []Entry) []gokeepasslib.G
|
|||||||
}
|
}
|
||||||
node.entries = append(node.entries, entry)
|
node.entries = append(node.entries, entry)
|
||||||
}
|
}
|
||||||
|
for _, path := range groupPathsForPersistence(model, entries) {
|
||||||
|
node := root
|
||||||
|
for _, segment := range path {
|
||||||
|
if node.children[segment] == nil {
|
||||||
|
node.children[segment] = &groupNode{
|
||||||
|
name: segment,
|
||||||
|
children: map[string]*groupNode{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
node = node.children[segment]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
groups := marshalGroups(db, root)
|
groups := marshalGroups(db, root)
|
||||||
if len(groups) > 0 {
|
if len(groups) > 0 {
|
||||||
@@ -261,6 +271,31 @@ func buildGroupTree(db *gokeepasslib.Database, entries []Entry) []gokeepasslib.G
|
|||||||
return []gokeepasslib.Group{group}
|
return []gokeepasslib.Group{group}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func groupPathsForPersistence(model Model, entries []Entry) [][]string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var groups [][]string
|
||||||
|
appendPath := func(path []string) {
|
||||||
|
key := strings.Join(path, "\x00")
|
||||||
|
if seen[key] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
groups = append(groups, slices.Clone(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
for i := 1; i <= len(entry.Path); i++ {
|
||||||
|
appendPath(entry.Path[:i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, path := range model.Groups {
|
||||||
|
for i := 1; i <= len(path); i++ {
|
||||||
|
appendPath(path[:i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
func LoadKDBXWithKey(r io.Reader, key MasterKey) (Model, error) {
|
func LoadKDBXWithKey(r io.Reader, key MasterKey) (Model, error) {
|
||||||
model, _, err := LoadKDBXWithConfig(r, key)
|
model, _, err := LoadKDBXWithConfig(r, key)
|
||||||
return model, err
|
return model, err
|
||||||
@@ -407,7 +442,7 @@ func isInvalidCredentialError(err error) bool {
|
|||||||
|
|
||||||
func marshalGroups(db *gokeepasslib.Database, node *groupNode) []gokeepasslib.Group {
|
func marshalGroups(db *gokeepasslib.Database, node *groupNode) []gokeepasslib.Group {
|
||||||
names := slices.Collect(maps.Keys(node.children))
|
names := slices.Collect(maps.Keys(node.children))
|
||||||
slices.Sort(names)
|
slices.SortFunc(names, compareGroupNames)
|
||||||
|
|
||||||
var groups []gokeepasslib.Group
|
var groups []gokeepasslib.Group
|
||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
@@ -422,6 +457,29 @@ func marshalGroups(db *gokeepasslib.Database, node *groupNode) []gokeepasslib.Gr
|
|||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func compareGroupNames(a, b string) int {
|
||||||
|
switch {
|
||||||
|
case a == b:
|
||||||
|
return 0
|
||||||
|
case a == "Root":
|
||||||
|
return -1
|
||||||
|
case b == "Root":
|
||||||
|
return 1
|
||||||
|
case a == templatesRoot:
|
||||||
|
return -1
|
||||||
|
case b == templatesRoot:
|
||||||
|
return 1
|
||||||
|
case a == recycleBinRoot:
|
||||||
|
return 1
|
||||||
|
case b == recycleBinRoot:
|
||||||
|
return -1
|
||||||
|
case a < b:
|
||||||
|
return -1
|
||||||
|
default:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func marshalEntries(db *gokeepasslib.Database, entries []Entry) []gokeepasslib.Entry {
|
func marshalEntries(db *gokeepasslib.Database, entries []Entry) []gokeepasslib.Entry {
|
||||||
slices.SortFunc(entries, func(a, b Entry) int {
|
slices.SortFunc(entries, func(a, b Entry) int {
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package vault
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/tobischo/gokeepasslib/v3"
|
"github.com/tobischo/gokeepasslib/v3"
|
||||||
@@ -602,6 +603,114 @@ func TestKDBXRoundTripsEntryAttachments(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := Model{
|
||||||
|
Entries: []Entry{
|
||||||
|
{
|
||||||
|
ID: "entry-1",
|
||||||
|
Title: "Vault Console",
|
||||||
|
Username: "dannyocean",
|
||||||
|
Password: "token-2",
|
||||||
|
URL: "https://vault.crew.example.invalid",
|
||||||
|
Notes: "Current credential",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
Attachments: map[string][]byte{
|
||||||
|
"token.txt": []byte("secret attachment contents"),
|
||||||
|
},
|
||||||
|
History: []Entry{
|
||||||
|
{
|
||||||
|
ID: "entry-1-history-1",
|
||||||
|
Title: "Vault Console",
|
||||||
|
Username: "dannyocean",
|
||||||
|
Password: "token-1",
|
||||||
|
URL: "https://vault.crew.example.invalid",
|
||||||
|
Notes: "Original credential",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Templates: []Entry{
|
||||||
|
{
|
||||||
|
ID: "tpl-1",
|
||||||
|
Title: "Website Login",
|
||||||
|
Username: "template-user",
|
||||||
|
Password: "template-password",
|
||||||
|
Path: []string{"Templates", "Web"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
RecycleBin: []Entry{
|
||||||
|
{
|
||||||
|
ID: "deleted-1",
|
||||||
|
Title: "Retired Entry",
|
||||||
|
Username: "archived-user",
|
||||||
|
Password: "retired-token",
|
||||||
|
Path: []string{"Root", "Archive"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Groups: [][]string{
|
||||||
|
{"Root", "Archive"},
|
||||||
|
{"Root", "Empty Group"},
|
||||||
|
{"Templates", "Web"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var encoded bytes.Buffer
|
||||||
|
if err := SaveKDBX(&encoded, model, "correct horse battery staple"); err != nil {
|
||||||
|
t.Fatalf("SaveKDBX(first cycle) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reopened, err := LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadKDBX(first cycle) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded.Reset()
|
||||||
|
if err := SaveKDBX(&encoded, reopened, "correct horse battery staple"); err != nil {
|
||||||
|
t.Fatalf("SaveKDBX(second cycle) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reopened, err = LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadKDBX(second cycle) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := reopened.EntriesInPath([]string{"Root", "Internet"})
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got))
|
||||||
|
}
|
||||||
|
if got[0].ID != "entry-1" {
|
||||||
|
t.Fatalf("entry ID after reopen cycles = %q, want %q", got[0].ID, "entry-1")
|
||||||
|
}
|
||||||
|
if len(got[0].History) != 1 {
|
||||||
|
t.Fatalf("len(History) after reopen cycles = %d, want 1", len(got[0].History))
|
||||||
|
}
|
||||||
|
if got[0].History[0].ID != "entry-1-history-1" {
|
||||||
|
t.Fatalf("history ID after reopen cycles = %q, want %q", got[0].History[0].ID, "entry-1-history-1")
|
||||||
|
}
|
||||||
|
if string(got[0].Attachments["token.txt"]) != "secret attachment contents" {
|
||||||
|
t.Fatalf("attachment after reopen cycles = %q, want %q", string(got[0].Attachments["token.txt"]), "secret attachment contents")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(reopened.Templates) != 1 || reopened.Templates[0].Path[1] != "Web" {
|
||||||
|
t.Fatalf("Templates after reopen cycles = %#v, want Website Login in Templates/Web", reopened.Templates)
|
||||||
|
}
|
||||||
|
if len(reopened.RecycleBin) != 1 || reopened.RecycleBin[0].Path[1] != "Archive" {
|
||||||
|
t.Fatalf("RecycleBin after reopen cycles = %#v, want recycled entry in Root/Archive", reopened.RecycleBin)
|
||||||
|
}
|
||||||
|
|
||||||
|
rootGroups := reopened.ChildGroups([]string{"Root"})
|
||||||
|
if !slices.Equal(rootGroups, []string{"Archive", "Empty Group", "Internet"}) {
|
||||||
|
t.Fatalf("ChildGroups(Root) after reopen cycles = %v, want [Archive Empty Group Internet]", rootGroups)
|
||||||
|
}
|
||||||
|
templateGroups := reopened.ChildGroups([]string{"Templates"})
|
||||||
|
if !slices.Equal(templateGroups, []string{"Web"}) {
|
||||||
|
t.Fatalf("ChildGroups(Templates) after reopen cycles = %v, want [Web]", templateGroups)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func mustGroup(name string, children ...any) gokeepasslib.Group {
|
func mustGroup(name string, children ...any) gokeepasslib.Group {
|
||||||
group := gokeepasslib.NewGroup()
|
group := gokeepasslib.NewGroup()
|
||||||
group.Name = name
|
group.Name = name
|
||||||
|
|||||||
Reference in New Issue
Block a user