Add local vault lifecycle UI coverage
This commit is contained in:
@@ -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)
|
||||
|
||||
+248
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user