Reconstruct KeePassGO repository
This commit is contained in:
@@ -0,0 +1,620 @@
|
||||
package appstate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/webdav"
|
||||
)
|
||||
|
||||
type Section string
|
||||
|
||||
const (
|
||||
SectionEntries Section = ""
|
||||
SectionTemplates Section = "templates"
|
||||
SectionRecycleBin Section = "recycle-bin"
|
||||
)
|
||||
|
||||
type CurrentSession interface {
|
||||
Current() (vault.Model, error)
|
||||
}
|
||||
|
||||
type MutableSession interface {
|
||||
CurrentSession
|
||||
Replace(vault.Model)
|
||||
}
|
||||
|
||||
type LockableSession interface {
|
||||
CurrentSession
|
||||
Lock() error
|
||||
Unlock(vault.MasterKey) error
|
||||
}
|
||||
|
||||
type SaveableSession interface {
|
||||
CurrentSession
|
||||
Save() error
|
||||
}
|
||||
|
||||
type CreateableSession interface {
|
||||
CurrentSession
|
||||
Create(vault.Model, vault.MasterKey) error
|
||||
}
|
||||
|
||||
type OpenableSession interface {
|
||||
CurrentSession
|
||||
Open(string, vault.MasterKey) error
|
||||
}
|
||||
|
||||
type SaveAsSession interface {
|
||||
CurrentSession
|
||||
SaveAs(string) error
|
||||
}
|
||||
|
||||
type RemoteOpenableSession interface {
|
||||
CurrentSession
|
||||
OpenRemote(webdav.Client, string, vault.MasterKey) error
|
||||
}
|
||||
|
||||
type State struct {
|
||||
Session CurrentSession
|
||||
Section Section
|
||||
CurrentPath []string
|
||||
SearchQuery string
|
||||
SelectedEntryID string
|
||||
Dirty bool
|
||||
}
|
||||
|
||||
func (s *State) VisibleEntries() ([]vault.Entry, error) {
|
||||
model, err := s.currentModel()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries := s.entriesForSection(model)
|
||||
if strings.TrimSpace(s.SearchQuery) != "" {
|
||||
return filterEntries(entries, s.SearchQuery), nil
|
||||
}
|
||||
|
||||
if s.Section == SectionEntries {
|
||||
return model.EntriesInPath(s.CurrentPath), nil
|
||||
}
|
||||
if s.Section == SectionRecycleBin || len(s.CurrentPath) == 0 {
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
return entriesInPath(entries, s.CurrentPath), nil
|
||||
}
|
||||
|
||||
func (s *State) ChildGroups() ([]string, error) {
|
||||
if strings.TrimSpace(s.SearchQuery) != "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
model, err := s.currentModel()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if s.Section != SectionEntries {
|
||||
if s.Section == SectionTemplates && len(s.CurrentPath) == 0 {
|
||||
return childGroups(s.entriesForSection(model), []string{"Templates"}), nil
|
||||
}
|
||||
return childGroups(s.entriesForSection(model), s.CurrentPath), nil
|
||||
}
|
||||
|
||||
return model.ChildGroups(s.CurrentPath), nil
|
||||
}
|
||||
|
||||
func (s *State) SelectVisibleIndex(index int) error {
|
||||
entries, err := s.VisibleEntries()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if index < 0 || index >= len(entries) {
|
||||
return fmt.Errorf("visible index %d out of range", index)
|
||||
}
|
||||
|
||||
s.SelectedEntryID = entries[index].ID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) ToggleVisibleIndex(index int) error {
|
||||
entries, err := s.VisibleEntries()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if index < 0 || index >= len(entries) {
|
||||
return fmt.Errorf("visible index %d out of range", index)
|
||||
}
|
||||
|
||||
if s.SelectedEntryID == entries[index].ID {
|
||||
s.SelectedEntryID = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
s.SelectedEntryID = entries[index].ID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) currentModel() (vault.Model, error) {
|
||||
if s.Session == nil {
|
||||
return vault.Model{}, nil
|
||||
}
|
||||
return s.Session.Current()
|
||||
}
|
||||
|
||||
func (s *State) entriesForSection(model vault.Model) []vault.Entry {
|
||||
switch s.Section {
|
||||
case SectionTemplates:
|
||||
return slices.Clone(model.Templates)
|
||||
case SectionRecycleBin:
|
||||
return slices.Clone(model.RecycleBin)
|
||||
default:
|
||||
return slices.Clone(model.Entries)
|
||||
}
|
||||
}
|
||||
|
||||
func entriesInPath(entries []vault.Entry, path []string) []vault.Entry {
|
||||
var out []vault.Entry
|
||||
for _, entry := range entries {
|
||||
if slices.Equal(entry.Path, path) {
|
||||
out = append(out, entry)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(out, func(a, b vault.Entry) int {
|
||||
switch {
|
||||
case a.Title < b.Title:
|
||||
return -1
|
||||
case a.Title > b.Title:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func filterEntries(entries []vault.Entry, query string) []vault.Entry {
|
||||
query = strings.TrimSpace(strings.ToLower(query))
|
||||
if query == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var out []vault.Entry
|
||||
for _, entry := range entries {
|
||||
haystack := strings.ToLower(
|
||||
entry.Title + " " +
|
||||
entry.Username + " " +
|
||||
entry.URL + " " +
|
||||
strings.Join(entry.Path, " "),
|
||||
)
|
||||
if !strings.Contains(haystack, query) {
|
||||
continue
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
slices.SortFunc(out, func(a, b vault.Entry) int {
|
||||
switch {
|
||||
case a.Title < b.Title:
|
||||
return -1
|
||||
case a.Title > b.Title:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func childGroups(entries []vault.Entry, path []string) []string {
|
||||
seen := map[string]bool{}
|
||||
var groups []string
|
||||
for _, entry := range entries {
|
||||
if len(path) > len(entry.Path) {
|
||||
continue
|
||||
}
|
||||
if !slices.Equal(entry.Path[:len(path)], path) {
|
||||
continue
|
||||
}
|
||||
if len(entry.Path) == len(path) {
|
||||
continue
|
||||
}
|
||||
group := entry.Path[len(path)]
|
||||
if seen[group] {
|
||||
continue
|
||||
}
|
||||
seen[group] = true
|
||||
groups = append(groups, group)
|
||||
}
|
||||
slices.Sort(groups)
|
||||
return groups
|
||||
}
|
||||
|
||||
func (s *State) DeleteSelectedEntry() error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not mutable")
|
||||
}
|
||||
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := model.DeleteEntry(s.SelectedEntryID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
session.Replace(model)
|
||||
s.SelectedEntryID = ""
|
||||
s.Dirty = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) RestoreEntry(id string) error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not mutable")
|
||||
}
|
||||
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := model.RestoreEntry(id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) UpsertEntry(entry vault.Entry) error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not mutable")
|
||||
}
|
||||
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
model.UpsertEntry(entry)
|
||||
session.Replace(model)
|
||||
s.SelectedEntryID = entry.ID
|
||||
s.Dirty = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) UpsertTemplate(entry vault.Entry) error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not mutable")
|
||||
}
|
||||
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
model.UpsertTemplate(entry)
|
||||
session.Replace(model)
|
||||
s.SelectedEntryID = entry.ID
|
||||
s.Dirty = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) InstantiateTemplate(templateID string, overrides vault.Entry) (vault.Entry, error) {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return vault.Entry{}, fmt.Errorf("session is not mutable")
|
||||
}
|
||||
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return vault.Entry{}, err
|
||||
}
|
||||
|
||||
entry, err := model.InstantiateTemplate(templateID, overrides)
|
||||
if err != nil {
|
||||
return vault.Entry{}, err
|
||||
}
|
||||
|
||||
session.Replace(model)
|
||||
s.SelectedEntryID = entry.ID
|
||||
s.Dirty = true
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func (s *State) DeleteTemplate(id string) error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not mutable")
|
||||
}
|
||||
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := model.DeleteTemplate(id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
session.Replace(model)
|
||||
if s.SelectedEntryID == id {
|
||||
s.SelectedEntryID = ""
|
||||
}
|
||||
s.Dirty = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) DuplicateSelectedEntry(duplicateID string) (vault.Entry, error) {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return vault.Entry{}, fmt.Errorf("session is not mutable")
|
||||
}
|
||||
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return vault.Entry{}, err
|
||||
}
|
||||
|
||||
duplicate, err := model.DuplicateEntry(s.SelectedEntryID, duplicateID)
|
||||
if err != nil {
|
||||
return vault.Entry{}, err
|
||||
}
|
||||
|
||||
session.Replace(model)
|
||||
s.SelectedEntryID = duplicate.ID
|
||||
s.Dirty = true
|
||||
return duplicate, nil
|
||||
}
|
||||
|
||||
func (s *State) RestoreSelectedEntryVersion(historyIndex int) error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not mutable")
|
||||
}
|
||||
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := model.RestoreEntryVersion(s.SelectedEntryID, historyIndex); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) Lock() error {
|
||||
session, ok := s.Session.(LockableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not lockable")
|
||||
}
|
||||
|
||||
if err := session.Lock(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.SelectedEntryID = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) Unlock(key vault.MasterKey) error {
|
||||
session, ok := s.Session.(LockableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not lockable")
|
||||
}
|
||||
|
||||
return session.Unlock(key)
|
||||
}
|
||||
|
||||
func (s *State) EnterGroup(name string) {
|
||||
s.CurrentPath = append(append([]string(nil), s.CurrentPath...), name)
|
||||
s.SelectedEntryID = ""
|
||||
}
|
||||
|
||||
func (s *State) NavigateToPath(path []string) {
|
||||
s.CurrentPath = append([]string(nil), path...)
|
||||
s.SelectedEntryID = ""
|
||||
}
|
||||
|
||||
func (s *State) Save() error {
|
||||
session, ok := s.Session.(SaveableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not saveable")
|
||||
}
|
||||
|
||||
if err := session.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.Dirty = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) CreateVault(key vault.MasterKey) error {
|
||||
session, ok := s.Session.(CreateableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not createable")
|
||||
}
|
||||
|
||||
if err := session.Create(vault.Model{}, key); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.CurrentPath = nil
|
||||
s.SelectedEntryID = ""
|
||||
s.Dirty = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) OpenVault(path string, key vault.MasterKey) error {
|
||||
session, ok := s.Session.(OpenableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not openable")
|
||||
}
|
||||
|
||||
if err := session.Open(path, key); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.CurrentPath = nil
|
||||
s.SelectedEntryID = ""
|
||||
s.Dirty = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) SaveAs(path string) error {
|
||||
session, ok := s.Session.(SaveAsSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not save-as capable")
|
||||
}
|
||||
|
||||
if err := session.SaveAs(path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.Dirty = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) OpenRemoteVault(client webdav.Client, path string, key vault.MasterKey) error {
|
||||
session, ok := s.Session.(RemoteOpenableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not remote-openable")
|
||||
}
|
||||
|
||||
if err := session.OpenRemote(client, path, key); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.CurrentPath = nil
|
||||
s.SelectedEntryID = ""
|
||||
s.Dirty = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) CreateGroup(name string) error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not mutable")
|
||||
}
|
||||
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
model.CreateGroup(s.CurrentPath, name)
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) RenameCurrentGroup(newName string) error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not mutable")
|
||||
}
|
||||
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := model.RenameGroup(s.CurrentPath, newName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
session.Replace(model)
|
||||
if len(s.CurrentPath) > 0 {
|
||||
s.CurrentPath = append(append([]string(nil), s.CurrentPath[:len(s.CurrentPath)-1]...), newName)
|
||||
}
|
||||
s.Dirty = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) MoveSelectedEntry(path []string) error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not mutable")
|
||||
}
|
||||
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := model.MoveEntry(s.SelectedEntryID, path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) AddAttachmentToSelectedEntry(name string, content []byte) error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not mutable")
|
||||
}
|
||||
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i := range model.Entries {
|
||||
if model.Entries[i].ID != s.SelectedEntryID {
|
||||
continue
|
||||
}
|
||||
if model.Entries[i].Attachments == nil {
|
||||
model.Entries[i].Attachments = map[string][]byte{}
|
||||
}
|
||||
model.Entries[i].Attachments[name] = append([]byte(nil), content...)
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return nil
|
||||
}
|
||||
|
||||
return vault.ErrEntryNotFound
|
||||
}
|
||||
|
||||
func (s *State) DeleteAttachmentFromSelectedEntry(name string) error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not mutable")
|
||||
}
|
||||
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i := range model.Entries {
|
||||
if model.Entries[i].ID != s.SelectedEntryID {
|
||||
continue
|
||||
}
|
||||
delete(model.Entries[i].Attachments, name)
|
||||
if len(model.Entries[i].Attachments) == 0 {
|
||||
model.Entries[i].Attachments = nil
|
||||
}
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return nil
|
||||
}
|
||||
|
||||
return vault.ErrEntryNotFound
|
||||
}
|
||||
@@ -0,0 +1,956 @@
|
||||
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: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
|
||||
{ID: "git-server", Title: "Git Server", Path: []string{"Joe", "Internet"}},
|
||||
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
CurrentPath: []string{"Joe", "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{"Dynadot", "Git Server"}) {
|
||||
t.Fatalf("visible titles = %v, want [Dynadot Git Server]", titles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
state := State{
|
||||
Session: stubSession{
|
||||
model: vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
|
||||
{ID: "git-server", Title: "Git Server", URL: "https://git.julianfamily.org", Path: []string{"Joe", "Internet"}},
|
||||
{ID: "ha-codex", Title: "Home Assistant (Codex)", URL: "https://lights.julianfamily.org", Path: []string{"Joe", "Home Assistant"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
CurrentPath: []string{"Joe", "Internet"},
|
||||
SearchQuery: "lights",
|
||||
}
|
||||
|
||||
got, err := state.VisibleEntries()
|
||||
if err != nil {
|
||||
t.Fatalf("VisibleEntries() error = %v", err)
|
||||
}
|
||||
|
||||
if len(got) != 1 || got[0].Title != "Home Assistant (Codex)" {
|
||||
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 TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
state := State{
|
||||
Session: stubSession{
|
||||
model: vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
|
||||
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}},
|
||||
{ID: "alma", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
CurrentPath: []string{"Joe"},
|
||||
}
|
||||
|
||||
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: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
|
||||
{ID: "git-server", Title: "Git Server", Path: []string{"Joe", "Internet"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
CurrentPath: []string{"Joe", "Internet"},
|
||||
}
|
||||
|
||||
if err := state.SelectVisibleIndex(1); err != nil {
|
||||
t.Fatalf("SelectVisibleIndex() error = %v", err)
|
||||
}
|
||||
if got := state.SelectedEntryID; got != "git-server" {
|
||||
t.Fatalf("SelectedEntryID = %q, want %q", got, "git-server")
|
||||
}
|
||||
|
||||
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: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
state := State{
|
||||
Session: sess,
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
SelectedEntryID: "git-server",
|
||||
}
|
||||
|
||||
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 != "git-server" {
|
||||
t.Fatalf("RecycleBin = %#v, want git-server 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: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
state := State{
|
||||
Session: sess,
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
}
|
||||
|
||||
if err := state.RestoreEntry("git-server"); 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 != "git-server" {
|
||||
t.Fatalf("VisibleEntries() = %#v, want restored git-server 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: "git-server",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
}
|
||||
if err := state.UpsertEntry(entry); err != nil {
|
||||
t.Fatalf("UpsertEntry() error = %v", err)
|
||||
}
|
||||
|
||||
if got := state.SelectedEntryID; got != "git-server" {
|
||||
t.Fatalf("SelectedEntryID = %q, want %q", got, "git-server")
|
||||
}
|
||||
|
||||
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 git-server 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: "dynadot",
|
||||
Title: "Dynadot",
|
||||
Username: "jjulian",
|
||||
Password: "hunter2",
|
||||
URL: "https://www.dynadot.com",
|
||||
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 != "dynadot" {
|
||||
t.Fatalf("SelectedEntryID = %q, want %q", got, "dynadot")
|
||||
}
|
||||
|
||||
got, err := state.VisibleEntries()
|
||||
if err != nil {
|
||||
t.Fatalf("VisibleEntries() error = %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != "dynadot" {
|
||||
t.Fatalf("VisibleEntries() = %#v, want instantiated dynadot 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: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
||||
},
|
||||
}}
|
||||
state := State{
|
||||
Session: sess,
|
||||
SelectedEntryID: "git-server",
|
||||
}
|
||||
|
||||
duplicate, err := state.DuplicateSelectedEntry("git-server-copy")
|
||||
if err != nil {
|
||||
t.Fatalf("DuplicateSelectedEntry() error = %v", err)
|
||||
}
|
||||
|
||||
if duplicate.ID != "git-server-copy" {
|
||||
t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "git-server-copy")
|
||||
}
|
||||
if state.SelectedEntryID != "git-server-copy" {
|
||||
t.Fatalf("SelectedEntryID = %q, want git-server-copy", state.SelectedEntryID)
|
||||
}
|
||||
if len(sess.model.Entries) != 2 {
|
||||
t.Fatalf("len(Entries) = %d, want 2 after duplicate", len(sess.model.Entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreSelectedEntryVersionReplacesCurrentVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sess := &mutableStubSession{model: vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "git-server",
|
||||
Title: "Git Server",
|
||||
Password: "new-token",
|
||||
Path: []string{"Root", "Internet"},
|
||||
History: []vault.Entry{
|
||||
{ID: "git-server-h1", Title: "Git Server", Password: "old-token", Path: []string{"Root", "Internet"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
state := State{
|
||||
Session: sess,
|
||||
SelectedEntryID: "git-server",
|
||||
}
|
||||
|
||||
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: "git-server",
|
||||
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: "git-server",
|
||||
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: "git-server",
|
||||
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: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
state := State{
|
||||
Session: sess,
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
SelectedEntryID: "git-server",
|
||||
}
|
||||
|
||||
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: "git-server", Title: "Git Server", 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 != "git-server" {
|
||||
t.Fatalf("VisibleEntries() = %#v, want git-server entry after unlock", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnterGroupAppendsPathAndClearsSelection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
state := State{
|
||||
CurrentPath: []string{"Root"},
|
||||
SelectedEntryID: "git-server",
|
||||
}
|
||||
|
||||
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: "git-server",
|
||||
}
|
||||
|
||||
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: "dynadot",
|
||||
}
|
||||
|
||||
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 != "dynadot" && got[1].ID != "dynadot" {
|
||||
t.Fatalf("VisibleEntries() = %#v, want moved dynadot 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: "dynadot",
|
||||
}
|
||||
|
||||
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: "dynadot",
|
||||
}
|
||||
|
||||
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: "dynadot", Title: "Dynadot", Path: []string{"Root", "Internet"}},
|
||||
{ID: "ha-codex", Title: "Home Assistant (Codex)", 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user