Files
keepassgo/appstate/state_test.go
T
2026-03-29 13:34:12 -07:00

1151 lines
29 KiB
Go

package appstate
import (
"errors"
"slices"
"testing"
"git.julianfamily.org/keepassgo/session"
"git.julianfamily.org/keepassgo/vault"
"git.julianfamily.org/keepassgo/webdav"
)
func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
},
},
},
CurrentPath: []string{"Crew", "Internet"},
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
titles := make([]string, 0, len(got))
for _, entry := range got {
titles = append(titles, entry.Title)
}
if !slices.Equal(titles, []string{"Bellagio", "Vault Console"}) {
t.Fatalf("visible titles = %v, want [Bellagio Vault Console]", titles)
}
}
func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
},
},
},
CurrentPath: []string{"Crew", "Internet"},
SearchQuery: "surveillance",
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].Title != "Surveillance Console" {
t.Fatalf("VisibleEntries() = %#v, want Home Assistant search match", got)
}
}
func TestVisibleEntriesUsesTemplateSection(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Templates: []vault.Entry{
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates", "Web"}},
{ID: "tpl-2", Title: "SSH Login", Path: []string{"Templates", "Infra"}},
},
},
},
Section: SectionTemplates,
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 2 {
t.Fatalf("len(VisibleEntries()) = %d, want 2 templates", len(got))
}
}
func TestVisibleEntriesUsesRecycleBinSection(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
RecycleBin: []vault.Entry{
{ID: "entry-1", Title: "Deleted Entry", Path: []string{"Root", "Internet"}},
},
},
},
Section: SectionRecycleBin,
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].ID != "entry-1" {
t.Fatalf("VisibleEntries() = %#v, want recycle-bin entry", got)
}
}
func TestVisibleEntriesUsesGlobalSearchWithinTemplateSection(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Templates: []vault.Entry{
{ID: "tpl-1", Title: "Website Login", URL: "https://accounts.example.com", Path: []string{"Templates", "Web"}},
{ID: "tpl-2", Title: "SSH Login", URL: "ssh://infra.internal", Path: []string{"Templates", "Infra"}},
},
},
},
Section: SectionTemplates,
CurrentPath: []string{"Templates", "Web"},
SearchQuery: "infra",
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].ID != "tpl-2" {
t.Fatalf("VisibleEntries() = %#v, want global template search result tpl-2", got)
}
}
func TestVisibleEntriesResetToCurrentTemplatePathAfterClearingSearch(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Templates: []vault.Entry{
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates", "Web"}},
{ID: "tpl-2", Title: "Email Login", Path: []string{"Templates", "Web"}},
{ID: "tpl-3", Title: "SSH Login", Path: []string{"Templates", "Infra"}},
},
},
},
Section: SectionTemplates,
CurrentPath: []string{"Templates", "Web"},
SearchQuery: "ssh",
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() with search error = %v", err)
}
if len(got) != 1 || got[0].ID != "tpl-3" {
t.Fatalf("VisibleEntries() with search = %#v, want tpl-3", got)
}
state.SearchQuery = ""
got, err = state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() after clearing search error = %v", err)
}
if len(got) != 2 {
t.Fatalf("len(VisibleEntries()) after clearing search = %d, want 2", len(got))
}
if titles := []string{got[0].Title, got[1].Title}; !slices.Equal(titles, []string{"Email Login", "Website Login"}) {
t.Fatalf("VisibleEntries() after clearing search titles = %v, want [Email Login Website Login]", titles)
}
}
func TestVisibleEntriesUsesGlobalSearchWithinRecycleBin(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
RecycleBin: []vault.Entry{
{ID: "deleted-1", Title: "Deleted Bellagio", Path: []string{"Root", "Internet"}},
{ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}},
},
},
},
Section: SectionRecycleBin,
CurrentPath: []string{"Root", "Internet"},
SearchQuery: "climate",
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].ID != "deleted-2" {
t.Fatalf("VisibleEntries() = %#v, want global recycle-bin search result deleted-2", got)
}
}
func TestSearchPathContextIncludesSectionRoots(t *testing.T) {
t.Parallel()
tests := []struct {
name string
section Section
entry vault.Entry
want string
}{
{
name: "entries use direct path",
section: SectionEntries,
entry: vault.Entry{Path: []string{"Root", "Internet"}},
want: "Root / Internet",
},
{
name: "templates retain templates root",
section: SectionTemplates,
entry: vault.Entry{Path: []string{"Templates", "Web"}},
want: "Templates / Web",
},
{
name: "recycle bin prefixes root label",
section: SectionRecycleBin,
entry: vault.Entry{Path: []string{"Root", "Internet"}},
want: "Recycle Bin / Root / Internet",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
state := State{Section: tt.section}
if got := state.SearchPathContext(tt.entry); got != tt.want {
t.Fatalf("SearchPathContext(%v) = %q, want %q", tt.entry.Path, got, tt.want)
}
})
}
}
func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
{ID: "alma", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}},
},
},
},
CurrentPath: []string{"Crew"},
}
got, err := state.ChildGroups()
if err != nil {
t.Fatalf("ChildGroups() error = %v", err)
}
if !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got)
}
}
func TestChildGroupsUsesTemplateSectionPaths(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Templates: []vault.Entry{
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates", "Web"}},
{ID: "tpl-2", Title: "SSH Login", Path: []string{"Templates", "Infra"}},
},
},
},
Section: SectionTemplates,
CurrentPath: []string{"Templates"},
}
got, err := state.ChildGroups()
if err != nil {
t.Fatalf("ChildGroups() error = %v", err)
}
if !slices.Equal(got, []string{"Infra", "Web"}) {
t.Fatalf("ChildGroups() = %v, want [Infra Web]", got)
}
}
func TestSelectVisibleEntryAndToggleSelection(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
},
},
},
CurrentPath: []string{"Crew", "Internet"},
}
if err := state.SelectVisibleIndex(1); err != nil {
t.Fatalf("SelectVisibleIndex() error = %v", err)
}
if got := state.SelectedEntryID; got != "vault-console" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "vault-console")
}
if err := state.ToggleVisibleIndex(1); err != nil {
t.Fatalf("ToggleVisibleIndex() error = %v", err)
}
if got := state.SelectedEntryID; got != "" {
t.Fatalf("SelectedEntryID after toggle = %q, want empty", got)
}
}
func TestVisibleEntriesFailsWhenVaultIsLocked(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{err: session.ErrLocked},
}
_, err := state.VisibleEntries()
if !errors.Is(err, session.ErrLocked) {
t.Fatalf("VisibleEntries() error = %v, want ErrLocked", err)
}
}
type stubSession struct {
model vault.Model
err error
}
func (s stubSession) Current() (vault.Model, error) {
if s.err != nil {
return vault.Model{}, s.err
}
return s.model, nil
}
func TestDeleteSelectedEntryUpdatesSessionAndClearsSelection(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
},
}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console",
}
if err := state.DeleteSelectedEntry(); err != nil {
t.Fatalf("DeleteSelectedEntry() error = %v", err)
}
if got := state.SelectedEntryID; got != "" {
t.Fatalf("SelectedEntryID = %q, want empty", got)
}
if len(sess.model.Entries) != 0 {
t.Fatalf("len(Entries) = %d, want 0", len(sess.model.Entries))
}
if len(sess.model.RecycleBin) != 1 || sess.model.RecycleBin[0].ID != "vault-console" {
t.Fatalf("RecycleBin = %#v, want vault-console entry", sess.model.RecycleBin)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after delete")
}
}
func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{
model: vault.Model{
RecycleBin: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
},
}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
}
if err := state.RestoreEntry("vault-console"); err != nil {
t.Fatalf("RestoreEntry() error = %v", err)
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].ID != "vault-console" {
t.Fatalf("VisibleEntries() = %#v, want restored vault-console entry", got)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after restore")
}
}
func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{
model: vault.Model{},
}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
}
entry := vault.Entry{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
}
if err := state.UpsertEntry(entry); err != nil {
t.Fatalf("UpsertEntry() error = %v", err)
}
if got := state.SelectedEntryID; got != "vault-console" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "vault-console")
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].Password != "token-1" {
t.Fatalf("VisibleEntries() = %#v, want persisted vault-console entry", got)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after upsert")
}
}
func TestInstantiateTemplateCreatesEntryAndSelectsIt(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{
model: vault.Model{
Templates: []vault.Entry{
{
ID: "website-login",
Title: "Website Login",
Username: "template-user",
Password: "template-password",
URL: "https://example.com",
Notes: "Reusable template for website accounts.",
Path: []string{"Templates"},
},
},
},
}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
}
entry, err := state.InstantiateTemplate("website-login", vault.Entry{
ID: "bellagio",
Title: "Bellagio",
Username: "rustyryan",
Password: "hunter2",
URL: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"},
})
if err != nil {
t.Fatalf("InstantiateTemplate() error = %v", err)
}
if entry.Notes != "Reusable template for website accounts." {
t.Fatalf("entry.Notes = %q, want template notes", entry.Notes)
}
if got := state.SelectedEntryID; got != "bellagio" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "bellagio")
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].ID != "bellagio" {
t.Fatalf("VisibleEntries() = %#v, want instantiated bellagio entry", got)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after template instantiation")
}
}
func TestUpsertTemplateCreatesTemplateAndMarksStateDirty(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{model: vault.Model{}}
state := State{Session: sess}
if err := state.UpsertTemplate(vault.Entry{
ID: "tpl-1",
Title: "Website Login",
Username: "template-user",
Path: []string{"Templates"},
}); err != nil {
t.Fatalf("UpsertTemplate() error = %v", err)
}
if len(sess.model.Templates) != 1 || sess.model.Templates[0].ID != "tpl-1" {
t.Fatalf("Templates = %#v, want tpl-1 template", sess.model.Templates)
}
if state.SelectedEntryID != "tpl-1" {
t.Fatalf("SelectedEntryID = %q, want tpl-1 after template upsert", state.SelectedEntryID)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after template upsert")
}
}
func TestDeleteTemplateRemovesTemplateAndClearsSelection(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{model: vault.Model{
Templates: []vault.Entry{
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates"}},
},
}}
state := State{
Session: sess,
SelectedEntryID: "tpl-1",
}
if err := state.DeleteTemplate("tpl-1"); err != nil {
t.Fatalf("DeleteTemplate() error = %v", err)
}
if len(sess.model.Templates) != 0 {
t.Fatalf("Templates = %#v, want empty after delete", sess.model.Templates)
}
if state.SelectedEntryID != "" {
t.Fatalf("SelectedEntryID = %q, want empty after delete", state.SelectedEntryID)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after template delete")
}
}
func TestDuplicateSelectedEntryCreatesCopyAndSelectsIt(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{model: vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
}}
state := State{
Session: sess,
SelectedEntryID: "vault-console",
}
duplicate, err := state.DuplicateSelectedEntry("vault-console-copy")
if err != nil {
t.Fatalf("DuplicateSelectedEntry() error = %v", err)
}
if duplicate.ID != "vault-console-copy" {
t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "vault-console-copy")
}
if state.SelectedEntryID != "vault-console-copy" {
t.Fatalf("SelectedEntryID = %q, want vault-console-copy", state.SelectedEntryID)
}
if len(sess.model.Entries) != 2 {
t.Fatalf("len(Entries) = %d, want 2 after duplicate", len(sess.model.Entries))
}
}
func TestMoveSelectedEntryMovesEntryToNewPathAndMarksDirty(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{model: vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
}}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console",
}
if err := state.MoveSelectedEntry([]string{"Root", "Infrastructure"}); err != nil {
t.Fatalf("MoveSelectedEntry() error = %v", err)
}
oldPath := sess.model.EntriesInPath([]string{"Root", "Internet"})
if len(oldPath) != 0 {
t.Fatalf("EntriesInPath(Root/Internet) = %#v, want empty after move", oldPath)
}
newPath := sess.model.EntriesInPath([]string{"Root", "Infrastructure"})
if len(newPath) != 1 || newPath[0].ID != "vault-console" {
t.Fatalf("EntriesInPath(Root/Infrastructure) = %#v, want moved vault-console entry", newPath)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after move")
}
}
func TestRestoreSelectedEntryVersionReplacesCurrentVersion(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{model: vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Password: "new-token",
Path: []string{"Root", "Internet"},
History: []vault.Entry{
{ID: "vault-console-h1", Title: "Vault Console", Password: "old-token", Path: []string{"Root", "Internet"}},
},
},
},
}}
state := State{
Session: sess,
SelectedEntryID: "vault-console",
}
if err := state.RestoreSelectedEntryVersion(0); err != nil {
t.Fatalf("RestoreSelectedEntryVersion() error = %v", err)
}
if got := sess.model.Entries[0].Password; got != "old-token" {
t.Fatalf("Entries[0].Password = %q, want %q", got, "old-token")
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after history restore")
}
}
func TestSaveClearsDirtyState(t *testing.T) {
t.Parallel()
sess := &saveableStubSession{}
state := State{
Session: sess,
Dirty: true,
}
if err := state.Save(); err != nil {
t.Fatalf("Save() error = %v", err)
}
if state.Dirty {
t.Fatal("Dirty = true, want false after save")
}
if sess.saveCalls != 1 {
t.Fatalf("saveCalls = %d, want 1", sess.saveCalls)
}
}
func TestCreateVaultResetsSelectionPathAndDirtyState(t *testing.T) {
t.Parallel()
sess := &lifecycleStubSession{}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console",
Dirty: true,
}
if err := state.CreateVault(vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
t.Fatalf("CreateVault() error = %v", err)
}
if sess.createCalls != 1 {
t.Fatalf("createCalls = %d, want 1", sess.createCalls)
}
if len(state.CurrentPath) != 0 {
t.Fatalf("CurrentPath = %v, want empty", state.CurrentPath)
}
if state.SelectedEntryID != "" {
t.Fatalf("SelectedEntryID = %q, want empty", state.SelectedEntryID)
}
if state.Dirty {
t.Fatal("Dirty = true, want false after create")
}
}
func TestOpenVaultResetsSelectionPathAndDirtyState(t *testing.T) {
t.Parallel()
sess := &lifecycleStubSession{}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console",
Dirty: true,
}
if err := state.OpenVault("/tmp/test.kdbx", vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
t.Fatalf("OpenVault() error = %v", err)
}
if sess.openPath != "/tmp/test.kdbx" {
t.Fatalf("openPath = %q, want %q", sess.openPath, "/tmp/test.kdbx")
}
if len(state.CurrentPath) != 0 {
t.Fatalf("CurrentPath = %v, want empty", state.CurrentPath)
}
if state.SelectedEntryID != "" {
t.Fatalf("SelectedEntryID = %q, want empty", state.SelectedEntryID)
}
if state.Dirty {
t.Fatal("Dirty = true, want false after open")
}
}
func TestSaveAsClearsDirtyState(t *testing.T) {
t.Parallel()
sess := &lifecycleStubSession{}
state := State{
Session: sess,
Dirty: true,
}
if err := state.SaveAs("/tmp/other.kdbx"); err != nil {
t.Fatalf("SaveAs() error = %v", err)
}
if sess.saveAsPath != "/tmp/other.kdbx" {
t.Fatalf("saveAsPath = %q, want %q", sess.saveAsPath, "/tmp/other.kdbx")
}
if state.Dirty {
t.Fatal("Dirty = true, want false after save-as")
}
}
func TestOpenRemoteVaultResetsSelectionPathAndDirtyState(t *testing.T) {
t.Parallel()
sess := &lifecycleStubSession{}
client := webdav.Client{BaseURL: "https://example.com"}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console",
Dirty: true,
}
if err := state.OpenRemoteVault(client, "vaults/main.kdbx", vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
t.Fatalf("OpenRemoteVault() error = %v", err)
}
if sess.remotePath != "vaults/main.kdbx" {
t.Fatalf("remotePath = %q, want %q", sess.remotePath, "vaults/main.kdbx")
}
if len(state.CurrentPath) != 0 {
t.Fatalf("CurrentPath = %v, want empty", state.CurrentPath)
}
if state.SelectedEntryID != "" {
t.Fatalf("SelectedEntryID = %q, want empty", state.SelectedEntryID)
}
if state.Dirty {
t.Fatal("Dirty = true, want false after remote open")
}
}
func TestLockClearsSelectionAndMakesVaultUnavailable(t *testing.T) {
t.Parallel()
sess := &lockableStubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
},
}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console",
}
if err := state.Lock(); err != nil {
t.Fatalf("Lock() error = %v", err)
}
if got := state.SelectedEntryID; got != "" {
t.Fatalf("SelectedEntryID = %q, want empty after lock", got)
}
_, err := state.VisibleEntries()
if !errors.Is(err, session.ErrLocked) {
t.Fatalf("VisibleEntries() error = %v, want ErrLocked", err)
}
}
func TestUnlockRestoresVaultVisibility(t *testing.T) {
t.Parallel()
sess := &lockableStubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
},
locked: true,
}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
}
if err := state.Unlock(vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
t.Fatalf("Unlock() error = %v", err)
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].ID != "vault-console" {
t.Fatalf("VisibleEntries() = %#v, want vault-console entry after unlock", got)
}
}
func TestChangeMasterKeyMarksStateDirty(t *testing.T) {
t.Parallel()
sess := &lifecycleStubSession{}
state := State{Session: sess}
key := vault.MasterKey{Password: "correct horse battery staple"}
if err := state.ChangeMasterKey(key); err != nil {
t.Fatalf("ChangeMasterKey() error = %v", err)
}
if got := sess.changedKey; got.Password != key.Password {
t.Fatalf("changedKey = %#v, want %#v", got, key)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after ChangeMasterKey")
}
}
func TestEnterGroupAppendsPathAndClearsSelection(t *testing.T) {
t.Parallel()
state := State{
CurrentPath: []string{"Root"},
SelectedEntryID: "vault-console",
}
state.EnterGroup("Internet")
if !slices.Equal(state.CurrentPath, []string{"Root", "Internet"}) {
t.Fatalf("CurrentPath = %v, want [Root Internet]", state.CurrentPath)
}
if got := state.SelectedEntryID; got != "" {
t.Fatalf("SelectedEntryID = %q, want empty", got)
}
}
func TestNavigateToPathReplacesPathAndClearsSelection(t *testing.T) {
t.Parallel()
state := State{
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console",
}
state.NavigateToPath([]string{"Root", "Home Assistant"})
if !slices.Equal(state.CurrentPath, []string{"Root", "Home Assistant"}) {
t.Fatalf("CurrentPath = %v, want [Root Home Assistant]", state.CurrentPath)
}
if got := state.SelectedEntryID; got != "" {
t.Fatalf("SelectedEntryID = %q, want empty", got)
}
}
func TestCreateGroupPersistsGroupAndMarksDirty(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{model: testVaultModel()}
state := State{
Session: sess,
CurrentPath: []string{"Root"},
}
if err := state.CreateGroup("Finance"); err != nil {
t.Fatalf("CreateGroup() error = %v", err)
}
got, err := state.ChildGroups()
if err != nil {
t.Fatalf("ChildGroups() error = %v", err)
}
if !slices.Equal(got, []string{"Finance", "Home Assistant", "Internet"}) {
t.Fatalf("ChildGroups() = %v, want Finance, Home Assistant, Internet", got)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after CreateGroup")
}
}
func TestRenameCurrentGroupUpdatesPathAndMarksDirty(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{model: testVaultModel()}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
}
if err := state.RenameCurrentGroup("Infra"); err != nil {
t.Fatalf("RenameCurrentGroup() error = %v", err)
}
if !slices.Equal(state.CurrentPath, []string{"Root", "Infra"}) {
t.Fatalf("CurrentPath = %v, want [Root Infra]", state.CurrentPath)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after RenameCurrentGroup")
}
}
func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{model: testVaultModel()}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "bellagio",
}
if err := state.MoveSelectedEntry([]string{"Root", "Home Assistant"}); err != nil {
t.Fatalf("MoveSelectedEntry() error = %v", err)
}
state.NavigateToPath([]string{"Root", "Home Assistant"})
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 2 {
t.Fatalf("len(VisibleEntries()) = %d, want 2", len(got))
}
if got[0].ID != "bellagio" && got[1].ID != "bellagio" {
t.Fatalf("VisibleEntries() = %#v, want moved bellagio entry in destination group", got)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after MoveSelectedEntry")
}
}
func TestAddAttachmentToSelectedEntryPersistsAndMarksDirty(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{model: testVaultModel()}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "bellagio",
}
if err := state.AddAttachmentToSelectedEntry("token.txt", []byte("secret")); err != nil {
t.Fatalf("AddAttachmentToSelectedEntry() error = %v", err)
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if string(got[0].Attachments["token.txt"]) != "secret" {
t.Fatalf("attachment content = %q, want %q", got[0].Attachments["token.txt"], "secret")
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after AddAttachmentToSelectedEntry")
}
}
func TestDeleteAttachmentFromSelectedEntryPersistsAndMarksDirty(t *testing.T) {
t.Parallel()
model := testVaultModel()
model.Entries[0].Attachments = map[string][]byte{"token.txt": []byte("secret")}
sess := &mutableStubSession{model: model}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "bellagio",
}
if err := state.DeleteAttachmentFromSelectedEntry("token.txt"); err != nil {
t.Fatalf("DeleteAttachmentFromSelectedEntry() error = %v", err)
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got[0].Attachments) != 0 {
t.Fatalf("attachments = %#v, want empty", got[0].Attachments)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after DeleteAttachmentFromSelectedEntry")
}
}
type mutableStubSession struct {
model vault.Model
err error
}
func (s *mutableStubSession) Current() (vault.Model, error) {
if s.err != nil {
return vault.Model{}, s.err
}
return s.model, nil
}
func (s *mutableStubSession) Replace(model vault.Model) {
s.model = model
}
func testVaultModel() vault.Model {
return vault.Model{
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"Root", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Root", "Home Assistant"}},
},
}
}
type lockableStubSession struct {
model vault.Model
locked bool
}
func (s *lockableStubSession) Current() (vault.Model, error) {
if s.locked {
return vault.Model{}, session.ErrLocked
}
return s.model, nil
}
func (s *lockableStubSession) Lock() error {
s.locked = true
return nil
}
func (s *lockableStubSession) Unlock(vault.MasterKey) error {
s.locked = false
return nil
}
type saveableStubSession struct {
saveCalls int
}
func (s *saveableStubSession) Current() (vault.Model, error) {
return vault.Model{}, nil
}
func (s *saveableStubSession) Save() error {
s.saveCalls++
return nil
}
type lifecycleStubSession struct {
createCalls int
openPath string
saveAsPath string
remotePath string
changedKey vault.MasterKey
}
func (s *lifecycleStubSession) Current() (vault.Model, error) {
return vault.Model{}, nil
}
func (s *lifecycleStubSession) Create(_ vault.Model, _ vault.MasterKey) error {
s.createCalls++
return nil
}
func (s *lifecycleStubSession) Open(path string, _ vault.MasterKey) error {
s.openPath = path
return nil
}
func (s *lifecycleStubSession) SaveAs(path string) error {
s.saveAsPath = path
return nil
}
func (s *lifecycleStubSession) OpenRemote(_ webdav.Client, path string, _ vault.MasterKey) error {
s.remotePath = path
return nil
}
func (s *lifecycleStubSession) ChangeMasterKey(key vault.MasterKey) error {
s.changedKey = key
return nil
}