Reconstruct KeePassGO repository

This commit is contained in:
Joe Julian
2026-03-29 11:04:38 -07:00
commit a2a8fcbd14
34 changed files with 14041 additions and 0 deletions
+620
View File
@@ -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
}
+956
View File
@@ -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: "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 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 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 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
}
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
}