From 2b535a90e4199bc46921b9257edd7e58478a0d95 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Sun, 29 Mar 2026 11:21:30 -0700 Subject: [PATCH] Add local vault lifecycle UI coverage --- main.go | 73 ++++++++++++--- main_test.go | 248 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 307 insertions(+), 14 deletions(-) diff --git a/main.go b/main.go index 1ec8d61..5297c57 100644 --- a/main.go +++ b/main.go @@ -134,6 +134,8 @@ type ui struct { state appstate.State masterKeyMode vault.MasterKeyMode visible []entry + currentPath []string + syncedPath []string selectedHistoryIndex int showPassword bool togglePassword widget.Clickable @@ -161,6 +163,11 @@ var ( selectedEdge = color.NRGBA{R: 73, G: 123, B: 100, A: 255} ) +const ( + errVaultPathRequired = "vault path is required" + errSaveAsPathRequired = "save-as path is required" +) + func newUI(mode string) *ui { return newUIWithSession(mode, &session.Manager{}) } @@ -233,6 +240,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession) *ui { func (u *ui) filter() { u.state.SearchQuery = u.search.Text() + u.syncCurrentPath() visible, err := u.state.VisibleEntries() if err != nil { u.visible = nil @@ -281,24 +289,25 @@ func (u *ui) selectedAttachmentNames() []string { func (u *ui) showEntriesSection() { u.state.Section = appstate.SectionEntries - u.state.NavigateToPath(nil) + u.setCurrentPath(nil) u.filter() } func (u *ui) showTemplatesSection() { u.state.Section = appstate.SectionTemplates - u.state.NavigateToPath(nil) + u.setCurrentPath(nil) u.filter() } func (u *ui) showRecycleBinSection() { u.state.Section = appstate.SectionRecycleBin - u.state.NavigateToPath(nil) + u.setCurrentPath(nil) u.filter() } func (u *ui) childGroups() []string { u.state.SearchQuery = u.search.Text() + u.syncCurrentPath() groups, err := u.state.ChildGroups() if err != nil { return nil @@ -374,10 +383,12 @@ func (u *ui) currentMasterKey() (vault.MasterKey, error) { return vault.MasterKey{}, fmt.Errorf("key file is required") } default: - if password == "" { - return vault.MasterKey{}, fmt.Errorf("master password is required") + if path == "" { + if password == "" { + return vault.MasterKey{}, fmt.Errorf("master password is required") + } + return vault.MasterKey{Password: password}, nil } - return vault.MasterKey{Password: password}, nil } content, err := os.ReadFile(path) @@ -406,6 +417,7 @@ func (u *ui) createVaultAction() error { if err := u.state.CreateVault(key); err != nil { return err } + u.currentPath = append([]string(nil), u.state.CurrentPath...) u.filter() return nil } @@ -415,9 +427,14 @@ func (u *ui) openVaultAction() error { if err != nil { return err } - if err := u.state.OpenVault(strings.TrimSpace(u.vaultPath.Text()), key); err != nil { + path := strings.TrimSpace(u.vaultPath.Text()) + if path == "" { + return errors.New(errVaultPathRequired) + } + if err := u.state.OpenVault(path, key); err != nil { return err } + u.currentPath = append([]string(nil), u.state.CurrentPath...) u.filter() return nil } @@ -431,7 +448,12 @@ func (u *ui) saveAction() error { } func (u *ui) saveAsAction() error { - if err := u.state.SaveAs(strings.TrimSpace(u.saveAsPath.Text())); err != nil { + path := strings.TrimSpace(u.saveAsPath.Text()) + if path == "" { + return errors.New(errSaveAsPathRequired) + } + + if err := u.state.SaveAs(path); err != nil { return err } u.filter() @@ -459,6 +481,7 @@ func (u *ui) lockAction() error { if err := u.state.Lock(); err != nil { return err } + u.currentPath = append([]string(nil), u.state.CurrentPath...) u.showPassword = false u.filter() return nil @@ -472,6 +495,7 @@ func (u *ui) unlockAction() error { if err := u.state.Unlock(key); err != nil { return err } + u.currentPath = append([]string(nil), u.state.CurrentPath...) u.filter() return nil } @@ -592,11 +616,30 @@ func (u *ui) detailPlaceholderMessage() string { } func (u *ui) ensureNavClickables() { - if len(u.breadcrumbs) < len(u.state.CurrentPath)+1 { - u.breadcrumbs = make([]widget.Clickable, len(u.state.CurrentPath)+1) + u.syncCurrentPath() + if len(u.breadcrumbs) < len(u.currentPath)+1 { + u.breadcrumbs = make([]widget.Clickable, len(u.currentPath)+1) } } +func (u *ui) setCurrentPath(path []string) { + u.currentPath = append([]string(nil), path...) + u.state.NavigateToPath(path) + u.syncedPath = append([]string(nil), path...) +} + +func (u *ui) syncCurrentPath() { + switch { + case slices.Equal(u.currentPath, u.syncedPath) && !slices.Equal(u.state.CurrentPath, u.syncedPath): + u.currentPath = append([]string(nil), u.state.CurrentPath...) + case !slices.Equal(u.currentPath, u.syncedPath) && slices.Equal(u.state.CurrentPath, u.syncedPath): + u.state.CurrentPath = append([]string(nil), u.currentPath...) + case !slices.Equal(u.currentPath, u.syncedPath) && !slices.Equal(u.state.CurrentPath, u.syncedPath): + u.state.CurrentPath = append([]string(nil), u.currentPath...) + } + u.syncedPath = append([]string(nil), u.currentPath...) +} + func (u *ui) layout(gtx layout.Context) layout.Dimensions { u.processShortcuts(gtx) for u.createVault.Clicked(gtx) { @@ -1271,9 +1314,10 @@ func (u *ui) pathBar(gtx layout.Context) layout.Dimensions { return lbl.Layout(gtx) } - crumbs := append([]string{"Vault"}, append([]string{}, u.state.CurrentPath...)...) + u.syncCurrentPath() + crumbs := append([]string{"Vault"}, append([]string{}, u.currentPath...)...) if u.state.Section == appstate.SectionTemplates { - crumbs = append([]string{"Templates"}, append([]string{}, u.state.CurrentPath...)...) + crumbs = append([]string{"Templates"}, append([]string{}, u.currentPath...)...) } return layout.Flex{Alignment: layout.Middle}.Layout(gtx, func() []layout.FlexChild { children := make([]layout.FlexChild, 0, len(crumbs)*2) @@ -1283,9 +1327,9 @@ func (u *ui) pathBar(gtx layout.Context) layout.Dimensions { children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { for u.breadcrumbs[index].Clicked(gtx) { if index == 0 { - u.state.NavigateToPath(nil) + u.setCurrentPath(nil) } else { - u.state.NavigateToPath(crumbs[1 : index+1]) + u.setCurrentPath(crumbs[1 : index+1]) } u.filter() } @@ -1323,6 +1367,7 @@ func (u *ui) groupBar(gtx layout.Context) layout.Dimensions { children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { for u.groupClicks[idx].Clicked(gtx) { u.state.EnterGroup(name) + u.currentPath = append([]string(nil), u.state.CurrentPath...) u.filter() } btn := material.Button(u.theme, &u.groupClicks[idx], "Folder: "+name) diff --git a/main_test.go b/main_test.go index 3070603..e6c577d 100644 --- a/main_test.go +++ b/main_test.go @@ -1817,3 +1817,251 @@ type failingClipboardWriter struct { func (w failingClipboardWriter) WriteText(string) error { return w.err } + +func TestUILocalLifecycleActionsUpdateVisibleStatusMessages(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.masterPassword.SetText("correct horse battery staple") + + u.runAction("create vault", u.createVaultAction) + if got := u.statusMessage; got != "create vault complete" { + t.Fatalf("status after create = %q, want %q", got, "create vault complete") + } + if got := u.errorMessage; got != "" { + t.Fatalf("error after create = %q, want empty", got) + } + + path := filepath.Join(t.TempDir(), "keepassgo.kdbx") + u.saveAsPath.SetText(path) + u.runAction("save-as vault", u.saveAsAction) + if got := u.statusMessage; got != "save-as vault complete" { + t.Fatalf("status after save-as = %q, want %q", got, "save-as vault complete") + } + if got := u.errorMessage; got != "" { + t.Fatalf("error after save-as = %q, want empty", got) + } + + if err := u.state.UpsertEntry(vault.Entry{ + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }); err != nil { + t.Fatalf("UpsertEntry() error = %v", err) + } + + u.runAction("save vault", u.saveAction) + if got := u.statusMessage; got != "save vault complete" { + t.Fatalf("status after save = %q, want %q", got, "save vault complete") + } + if got := u.errorMessage; got != "" { + t.Fatalf("error after save = %q, want empty", got) + } + + u.runAction("lock vault", u.lockAction) + if got := u.statusMessage; got != "lock vault complete" { + t.Fatalf("status after lock = %q, want %q", got, "lock vault complete") + } + if got := u.errorMessage; got != "" { + t.Fatalf("error after lock = %q, want empty", got) + } + + u.runAction("unlock vault", u.unlockAction) + if got := u.statusMessage; got != "unlock vault complete" { + t.Fatalf("status after unlock = %q, want %q", got, "unlock vault complete") + } + if got := u.errorMessage; got != "" { + t.Fatalf("error after unlock = %q, want empty", got) + } + + reopened := newUIWithSession("desktop", &session.Manager{}) + reopened.masterPassword.SetText("correct horse battery staple") + reopened.vaultPath.SetText(path) + reopened.runAction("open vault", reopened.openVaultAction) + if got := reopened.statusMessage; got != "open vault complete" { + t.Fatalf("status after open = %q, want %q", got, "open vault complete") + } + if got := reopened.errorMessage; got != "" { + t.Fatalf("error after open = %q, want empty", got) + } +} + +func TestUILocalLifecycleActionErrorsAreVisibleAndSpecific(t *testing.T) { + t.Parallel() + + t.Run("save without configured path", func(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.masterPassword.SetText("correct horse battery staple") + + u.runAction("create vault", u.createVaultAction) + u.runAction("save vault", u.saveAction) + + if got := u.statusMessage; got != "" { + t.Fatalf("status after failed save = %q, want empty", got) + } + if got := u.errorMessage; !strings.Contains(got, session.ErrNoPath.Error()) { + t.Fatalf("error after failed save = %q, want %q", got, session.ErrNoPath.Error()) + } + }) + + t.Run("save-as without target path", func(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.masterPassword.SetText("correct horse battery staple") + + u.runAction("create vault", u.createVaultAction) + u.runAction("save-as vault", u.saveAsAction) + + if got := u.statusMessage; got != "" { + t.Fatalf("status after failed save-as = %q, want empty", got) + } + if got := u.errorMessage; got != "save-as path is required" { + t.Fatalf("error after failed save-as = %q, want %q", got, "save-as path is required") + } + }) + + t.Run("open without target path", func(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.masterPassword.SetText("correct horse battery staple") + + u.runAction("open vault", u.openVaultAction) + + if got := u.statusMessage; got != "" { + t.Fatalf("status after failed open = %q, want empty", got) + } + if got := u.errorMessage; got != "vault path is required" { + t.Fatalf("error after failed open = %q, want %q", got, "vault path is required") + } + }) + + t.Run("open unreadable path", func(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.masterPassword.SetText("correct horse battery staple") + u.vaultPath.SetText(filepath.Join(t.TempDir(), "missing.kdbx")) + + u.runAction("open vault", u.openVaultAction) + + if got := u.statusMessage; got != "" { + t.Fatalf("status after unreadable open = %q, want empty", got) + } + if got := u.errorMessage; got == "" || !strings.Contains(got, "read ") { + t.Fatalf("error after unreadable open = %q, want read failure", got) + } + }) + + t.Run("open decode failure", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "corrupt.kdbx") + if err := os.WriteFile(path, []byte("not-a-kdbx"), 0o600); err != nil { + t.Fatalf("WriteFile(corrupt) error = %v", err) + } + + u := newUIWithSession("desktop", &session.Manager{}) + u.masterPassword.SetText("correct horse battery staple") + u.vaultPath.SetText(path) + + u.runAction("open vault", u.openVaultAction) + + if got := u.statusMessage; got != "" { + t.Fatalf("status after decode failure = %q, want empty", got) + } + if got := u.errorMessage; got == "" || !strings.Contains(got, "decode kdbx") { + t.Fatalf("error after decode failure = %q, want decode kdbx failure", got) + } + }) + + t.Run("open invalid master key", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "vault.kdbx") + var encoded bytes.Buffer + if err := vault.SaveKDBX(&encoded, vault.Model{}, "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(vault) error = %v", err) + } + + u := newUIWithSession("desktop", &session.Manager{}) + u.masterPassword.SetText("wrong password") + u.vaultPath.SetText(path) + + u.runAction("open vault", u.openVaultAction) + + if got := u.statusMessage; got != "" { + t.Fatalf("status after invalid master open = %q, want empty", got) + } + if got := u.errorMessage; !strings.Contains(got, vault.ErrInvalidMasterKey.Error()) { + t.Fatalf("error after invalid master open = %q, want %q", got, vault.ErrInvalidMasterKey.Error()) + } + }) + + t.Run("unlock invalid master key", func(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.masterPassword.SetText("correct horse battery staple") + if err := u.createVaultAction(); err != nil { + t.Fatalf("createVaultAction() error = %v", err) + } + if err := u.lockAction(); err != nil { + t.Fatalf("lockAction() error = %v", err) + } + + u.masterPassword.SetText("wrong password") + u.runAction("unlock vault", u.unlockAction) + + if got := u.statusMessage; got != "" { + t.Fatalf("status after invalid unlock = %q, want empty", got) + } + if got := u.errorMessage; !strings.Contains(got, vault.ErrInvalidMasterKey.Error()) { + t.Fatalf("error after invalid unlock = %q, want %q", got, vault.ErrInvalidMasterKey.Error()) + } + }) +} + +func TestUILocalLifecycleActionsClearStaleMessagesOnSuccess(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.masterPassword.SetText("correct horse battery staple") + + u.runAction("save vault", u.saveAction) + if u.errorMessage == "" { + t.Fatal("error after failed save = empty, want visible failure") + } + + u.runAction("create vault", u.createVaultAction) + if got := u.errorMessage; got != "" { + t.Fatalf("error after create = %q, want cleared", got) + } + if got := u.statusMessage; got != "create vault complete" { + t.Fatalf("status after create = %q, want %q", got, "create vault complete") + } +} + +func TestUICurrentMasterKeyReportsUnreadableKeyFile(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.keyFilePath.SetText(filepath.Join(t.TempDir(), "missing.key")) + + _, err := u.currentMasterKey() + if err == nil { + t.Fatal("currentMasterKey() error = nil, want read failure") + } + if !errors.Is(err, os.ErrNotExist) { + t.Fatalf("currentMasterKey() error = %v, want os.ErrNotExist", err) + } +}