Files
keepassgo/main_test.go
T
2026-03-29 17:27:19 -07:00

2713 lines
86 KiB
Go

package main
import (
"bytes"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"gioui.org/io/key"
"gioui.org/unit"
"git.julianfamily.org/keepassgo/clipboard"
"git.julianfamily.org/keepassgo/passwords"
"git.julianfamily.org/keepassgo/session"
"git.julianfamily.org/keepassgo/vault"
"git.julianfamily.org/keepassgo/webdav"
)
func TestUIFiltersUsingVaultModelPathsAndSearch(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{ID: "1", Title: "Bellagio", Username: "rustyryan", URL: "https://bellagio.example.invalid", Path: []string{"Crew", "Internet"}},
{ID: "2", Title: "Vault Console", Username: "dannyocean", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
{ID: "3", Title: "Surveillance Console", Username: "codex", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
},
})
u.state.NavigateToPath([]string{"Crew", "Internet"})
u.filter()
if got := u.filteredTitles(); !slices.Equal(got, []string{"Bellagio", "Vault Console"}) {
t.Fatalf("filteredTitles() = %v, want [Bellagio Vault Console]", got)
}
u.search.SetText("surveillance")
u.filter()
if got := u.filteredTitles(); !slices.Equal(got, []string{"Surveillance Console"}) {
t.Fatalf("search filteredTitles() = %v, want [Surveillance Console]", got)
}
}
func TestUISearchBehaviorIsConsistentAcrossDesktopAndPhoneLayouts(t *testing.T) {
t.Parallel()
modes := []string{"desktop", "phone"}
for _, mode := range modes {
mode := mode
t.Run(mode, func(t *testing.T) {
t.Parallel()
u := newUIWithModel(mode, vault.Model{
Entries: []vault.Entry{
{ID: "entry-1", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}},
{ID: "entry-2", Title: "HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}},
},
Templates: []vault.Entry{
{ID: "tpl-1", Title: "Website Login", URL: "https://accounts.example.com", Path: []string{"Templates", "Web"}},
{ID: "tpl-2", Title: "SSH Login", URL: "ssh://infra.internal", Path: []string{"Templates", "Infra"}},
},
RecycleBin: []vault.Entry{
{ID: "deleted-1", Title: "Deleted Bellagio", URL: "https://bellagio.example.com", Path: []string{"Root", "Internet"}},
{ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}},
},
})
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.search.SetText("climate")
u.filter()
if got := u.filteredTitles(); !slices.Equal(got, []string{"HVAC"}) {
t.Fatalf("entries filteredTitles() = %v, want [HVAC]", got)
}
u.showTemplatesSection()
u.state.NavigateToPath([]string{"Templates", "Web"})
u.search.SetText("infra")
u.filter()
if got := u.filteredTitles(); !slices.Equal(got, []string{"SSH Login"}) {
t.Fatalf("templates filteredTitles() = %v, want [SSH Login]", got)
}
if got := u.visiblePathContexts(); !slices.Equal(got, []string{"Templates / Infra"}) {
t.Fatalf("templates visiblePathContexts() = %v, want [Templates / Infra]", got)
}
u.showRecycleBinSection()
u.search.SetText("climate")
u.filter()
if got := u.filteredTitles(); !slices.Equal(got, []string{"Deleted HVAC"}) {
t.Fatalf("recycle filteredTitles() = %v, want [Deleted HVAC]", got)
}
if got := u.visiblePathContexts(); !slices.Equal(got, []string{"Recycle Bin / Root / Home"}) {
t.Fatalf("recycle visiblePathContexts() = %v, want [Recycle Bin / Root / Home]", got)
}
})
}
}
func TestUIClearingSearchResetsToCurrentSectionListing(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Templates: []vault.Entry{
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates", "Web"}},
{ID: "tpl-2", Title: "Email Login", Path: []string{"Templates", "Web"}},
{ID: "tpl-3", Title: "SSH Login", Path: []string{"Templates", "Infra"}},
},
})
u.showTemplatesSection()
u.state.NavigateToPath([]string{"Templates", "Web"})
u.search.SetText("ssh")
u.filter()
if got := u.filteredTitles(); !slices.Equal(got, []string{"SSH Login"}) {
t.Fatalf("filteredTitles() with search = %v, want [SSH Login]", got)
}
u.search.SetText("")
u.filter()
if got := u.filteredTitles(); !slices.Equal(got, []string{"Email Login", "Website Login"}) {
t.Fatalf("filteredTitles() after clearing search = %v, want [Email Login Website Login]", got)
}
}
func TestUIChildGroupsComeFromVaultModel(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{ID: "1", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "2", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
{ID: "3", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}},
},
})
u.state.NavigateToPath([]string{"Crew"})
if got := u.childGroups(); !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
t.Fatalf("childGroups() = %v, want [Home Assistant Internet]", got)
}
}
func TestUISelectedEntryFollowsApplicationStateSelection(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{ID: "1", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "2", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
},
})
u.state.NavigateToPath([]string{"Crew", "Internet"})
u.filter()
u.state.SelectedEntryID = "2"
got, ok := u.selectedEntry()
if !ok {
t.Fatal("selectedEntry() ok = false, want true")
}
if got.Title != "Vault Console" {
t.Fatalf("selectedEntry().Title = %q, want %q", got.Title, "Vault Console")
}
}
func TestUILockHidesVisibleEntries(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{ID: "1", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
},
})
if err := u.state.Lock(); err != nil {
t.Fatalf("state.Lock() error = %v", err)
}
u.filter()
if got := u.filteredTitles(); len(got) != 0 {
t.Fatalf("filteredTitles() = %v, want empty while locked", got)
}
}
func TestUILifecycleActionsCreateSaveOpenLockAndUnlockLocalVault(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 got := u.masterPassword.Text(); got != "" {
t.Fatalf("masterPassword after create = %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)
}
path := filepath.Join(t.TempDir(), "keepassgo.kdbx")
u.saveAsPath.SetText(path)
if err := u.saveAsAction(); err != nil {
t.Fatalf("saveAsAction() error = %v", err)
}
if err := u.lockAction(); err != nil {
t.Fatalf("lockAction() error = %v", err)
}
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
if got := u.filteredTitles(); len(got) != 0 {
t.Fatalf("filteredTitles() = %v, want empty while locked", got)
}
u.masterPassword.SetText("correct horse battery staple")
if err := u.unlockAction(); err != nil {
t.Fatalf("unlockAction() error = %v", err)
}
u.filter()
if got := u.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) {
t.Fatalf("filteredTitles() after unlock = %v, want [Vault Console]", got)
}
reopened := newUIWithSession("desktop", &session.Manager{})
reopened.masterPassword.SetText("correct horse battery staple")
reopened.vaultPath.SetText(path)
if err := reopened.openVaultAction(); err != nil {
t.Fatalf("openVaultAction() error = %v", err)
}
if got := reopened.masterPassword.Text(); got != "" {
t.Fatalf("masterPassword after open = %q, want empty", got)
}
reopened.state.NavigateToPath([]string{"Root", "Internet"})
reopened.filter()
if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) {
t.Fatalf("reopened filteredTitles() = %v, want [Vault Console]", got)
}
}
func TestUILockAndUnlockClearMasterPasswordField(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)
}
u.masterPassword.SetText("should-be-cleared")
if err := u.lockAction(); err != nil {
t.Fatalf("lockAction() error = %v", err)
}
if got := u.masterPassword.Text(); got != "" {
t.Fatalf("masterPassword after lock = %q, want empty", got)
}
u.masterPassword.SetText("correct horse battery staple")
if err := u.unlockAction(); err != nil {
t.Fatalf("unlockAction() error = %v", err)
}
if got := u.masterPassword.Text(); got != "" {
t.Fatalf("masterPassword after unlock = %q, want empty", got)
}
}
func TestUIMasterKeyModesCreateOpenAndUnlockLocalVault(t *testing.T) {
t.Parallel()
tests := []struct {
name string
mode vault.MasterKeyMode
password string
keyFileData []byte
}{
{
name: "password only",
mode: vault.MasterKeyModePasswordOnly,
password: "correct horse battery staple",
},
{
name: "key file only",
mode: vault.MasterKeyModeKeyFileOnly,
keyFileData: []byte("key-file-only-material"),
},
{
name: "composite",
mode: vault.MasterKeyModePasswordAndKeyFile,
password: "correct horse battery staple",
keyFileData: []byte("composite-key-material"),
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
keyFile := ""
if len(tt.keyFileData) > 0 {
keyFile = filepath.Join(t.TempDir(), "master.key")
if err := os.WriteFile(keyFile, tt.keyFileData, 0o600); err != nil {
t.Fatalf("WriteFile(master.key) error = %v", err)
}
}
u := newUIWithSession("desktop", &session.Manager{})
u.setMasterKeyMode(tt.mode)
u.masterPassword.SetText(tt.password)
u.keyFilePath.SetText(keyFile)
if err := u.createVaultAction(); err != nil {
t.Fatalf("createVaultAction() error = %v", err)
}
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)
}
path := filepath.Join(t.TempDir(), "keepassgo.kdbx")
u.saveAsPath.SetText(path)
if err := u.saveAsAction(); err != nil {
t.Fatalf("saveAsAction() error = %v", err)
}
if err := u.lockAction(); err != nil {
t.Fatalf("lockAction() error = %v", err)
}
u.masterPassword.SetText(tt.password)
u.keyFilePath.SetText(keyFile)
if err := u.unlockAction(); err != nil {
t.Fatalf("unlockAction() error = %v", err)
}
reopened := newUIWithSession("desktop", &session.Manager{})
reopened.setMasterKeyMode(tt.mode)
reopened.masterPassword.SetText(tt.password)
reopened.keyFilePath.SetText(keyFile)
reopened.vaultPath.SetText(path)
if err := reopened.openVaultAction(); err != nil {
t.Fatalf("openVaultAction() error = %v", err)
}
reopened.state.NavigateToPath([]string{"Root", "Internet"})
reopened.filter()
if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) {
t.Fatalf("reopened filteredTitles() = %v, want [Vault Console]", got)
}
})
}
}
func TestUIChangeMasterKeyModeForExistingVault(t *testing.T) {
t.Parallel()
updated := filepath.Join(t.TempDir(), "updated.key")
if err := os.WriteFile(updated, []byte("updated-key"), 0o600); err != nil {
t.Fatalf("WriteFile(updated.key) error = %v", err)
}
u := newUIWithSession("desktop", &session.Manager{})
u.setMasterKeyMode(vault.MasterKeyModePasswordOnly)
u.masterPassword.SetText("old-password")
if err := u.createVaultAction(); err != nil {
t.Fatalf("createVaultAction() error = %v", err)
}
if err := u.state.UpsertEntry(vault.Entry{
ID: "vault-console",
Title: "Vault Console",
Path: []string{"Root", "Internet"},
}); err != nil {
t.Fatalf("UpsertEntry() error = %v", err)
}
path := filepath.Join(t.TempDir(), "keepassgo.kdbx")
u.saveAsPath.SetText(path)
if err := u.saveAsAction(); err != nil {
t.Fatalf("saveAsAction() error = %v", err)
}
u.setMasterKeyMode(vault.MasterKeyModePasswordAndKeyFile)
u.masterPassword.SetText("new-password")
u.keyFilePath.SetText(updated)
if err := u.changeMasterKeyAction(); err != nil {
t.Fatalf("changeMasterKeyAction() error = %v", err)
}
if err := u.saveAction(); err != nil {
t.Fatalf("saveAction() error = %v", err)
}
if err := u.lockAction(); err != nil {
t.Fatalf("lockAction() error = %v", err)
}
u.masterPassword.SetText("old-password")
u.keyFilePath.SetText("")
u.setMasterKeyMode(vault.MasterKeyModePasswordOnly)
u.runAction("unlock vault", u.unlockAction)
if u.state.ErrorMessage == "" {
t.Fatal("state.ErrorMessage = empty, want visible invalid master key error")
}
u.setMasterKeyMode(vault.MasterKeyModePasswordAndKeyFile)
u.masterPassword.SetText("new-password")
u.keyFilePath.SetText(updated)
if err := u.unlockAction(); err != nil {
t.Fatalf("unlockAction() with updated key error = %v", err)
}
reopened := newUIWithSession("desktop", &session.Manager{})
reopened.setMasterKeyMode(vault.MasterKeyModePasswordAndKeyFile)
reopened.masterPassword.SetText("new-password")
reopened.keyFilePath.SetText(updated)
reopened.vaultPath.SetText(path)
if err := reopened.openVaultAction(); err != nil {
t.Fatalf("openVaultAction() with updated key error = %v", err)
}
reopened.state.NavigateToPath([]string{"Root", "Internet"})
reopened.filter()
if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) {
t.Fatalf("reopened filteredTitles() = %v, want [Vault Console]", got)
}
}
func TestUIMasterKeyValidationErrorsAreVisible(t *testing.T) {
t.Parallel()
tests := []struct {
name string
password string
keyFile string
wantError string
}{
{
name: "requires either password or key file",
wantError: "master password or key file is required",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
u := newUIWithSession("desktop", &session.Manager{})
u.masterPassword.SetText(tt.password)
u.keyFilePath.SetText(tt.keyFile)
u.runAction("create vault", u.createVaultAction)
if got := u.state.ErrorMessage; got != tt.wantError {
t.Fatalf("state.ErrorMessage = %q, want %q", got, tt.wantError)
}
if got := u.state.StatusMessage; got != "" {
t.Fatalf("state.StatusMessage = %q, want empty on validation error", got)
}
})
}
}
func TestUIUnreadableAndInvalidMasterKeyErrorsAreVisible(t *testing.T) {
t.Parallel()
keyFile := filepath.Join(t.TempDir(), "master.key")
if err := os.WriteFile(keyFile, []byte("key-material"), 0o600); err != nil {
t.Fatalf("WriteFile(master.key) error = %v", err)
}
create := newUIWithSession("desktop", &session.Manager{})
create.setMasterKeyMode(vault.MasterKeyModeKeyFileOnly)
create.keyFilePath.SetText(keyFile)
if err := create.createVaultAction(); err != nil {
t.Fatalf("createVaultAction() error = %v", err)
}
path := filepath.Join(t.TempDir(), "keepassgo.kdbx")
create.saveAsPath.SetText(path)
if err := create.saveAsAction(); err != nil {
t.Fatalf("saveAsAction() error = %v", err)
}
unreadable := newUIWithSession("desktop", &session.Manager{})
unreadable.setMasterKeyMode(vault.MasterKeyModeKeyFileOnly)
unreadable.keyFilePath.SetText(filepath.Join(t.TempDir(), "missing.key"))
unreadable.runAction("open vault", unreadable.openVaultAction)
if got := unreadable.state.ErrorMessage; got == "" || got[:14] != "read key file:" {
t.Fatalf("state.ErrorMessage = %q, want read key file error", got)
}
wrong := newUIWithSession("desktop", &session.Manager{})
wrong.setMasterKeyMode(vault.MasterKeyModeKeyFileOnly)
wrong.keyFilePath.SetText(filepath.Join(t.TempDir(), "wrong.key"))
if err := os.WriteFile(wrong.keyFilePath.Text(), []byte("wrong-key"), 0o600); err != nil {
t.Fatalf("WriteFile(wrong.key) error = %v", err)
}
wrong.vaultPath.SetText(path)
wrong.runAction("open vault", wrong.openVaultAction)
if got := wrong.state.ErrorMessage; got == "" || !bytes.Contains([]byte(got), []byte(vault.ErrInvalidMasterKey.Error())) {
t.Fatalf("state.ErrorMessage = %q, want invalid master key error", got)
}
}
func TestUIOpenRemoteAndSaveThroughConfiguredWebDAVTarget(t *testing.T) {
t.Parallel()
key := vault.MasterKey{Password: "correct horse battery staple"}
model := vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
}
var putCount int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
var encoded bytes.Buffer
if err := vault.SaveKDBXWithKey(&encoded, model, key); err != nil {
t.Fatalf("SaveKDBXWithKey() error = %v", err)
}
w.Header().Set("ETag", "\"v1\"")
_, _ = w.Write(encoded.Bytes())
case http.MethodPut:
putCount++
w.Header().Set("ETag", "\"v2\"")
w.WriteHeader(http.StatusNoContent)
default:
t.Fatalf("unexpected method %s", r.Method)
}
}))
defer server.Close()
u := newUIWithSession("desktop", &session.Manager{})
u.masterPassword.SetText("correct horse battery staple")
u.remoteBaseURL.SetText(server.URL)
u.remotePath.SetText("vaults/main.kdbx")
if err := u.openRemoteAction(); err != nil {
t.Fatalf("openRemoteAction() error = %v", err)
}
if err := u.state.UpsertEntry(vault.Entry{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-2",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
}); err != nil {
t.Fatalf("UpsertEntry() error = %v", err)
}
if err := u.saveAction(); err != nil {
t.Fatalf("saveAction() error = %v", err)
}
if putCount != 1 {
t.Fatalf("remote PUT count = %d, want 1", putCount)
}
}
func TestUIOpenRemoteReportsTransportFailure(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
url := server.URL
server.Close()
u := newUIWithSession("desktop", &session.Manager{})
u.masterPassword.SetText("correct horse battery staple")
u.remoteBaseURL.SetText(url)
u.remotePath.SetText("vaults/main.kdbx")
u.runAction("open remote vault", u.openRemoteAction)
if got := u.state.ErrorMessage; !strings.Contains(got, "open remote vault failed:") {
t.Fatalf("state.ErrorMessage = %q, want open remote vault failure", got)
}
if got := u.state.StatusMessage; got != "" {
t.Fatalf("state.StatusMessage = %q, want empty on remote open failure", got)
}
}
func TestUIRemoteSaveConflictShowsVisibleErrorAndKeepsDirtyState(t *testing.T) {
t.Parallel()
key := vault.MasterKey{Password: "correct horse battery staple"}
model := vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
}
var putCount int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
var encoded bytes.Buffer
if err := vault.SaveKDBXWithKey(&encoded, model, key); err != nil {
t.Fatalf("SaveKDBXWithKey() error = %v", err)
}
w.Header().Set("ETag", "\"v1\"")
_, _ = w.Write(encoded.Bytes())
case http.MethodPut:
putCount++
w.WriteHeader(http.StatusPreconditionFailed)
default:
t.Fatalf("unexpected method %s", r.Method)
}
}))
defer server.Close()
u := newUIWithSession("desktop", &session.Manager{})
u.masterPassword.SetText("correct horse battery staple")
u.remoteBaseURL.SetText(server.URL)
u.remotePath.SetText("vaults/main.kdbx")
if err := u.openRemoteAction(); err != nil {
t.Fatalf("openRemoteAction() error = %v", err)
}
if err := u.state.UpsertEntry(vault.Entry{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-2",
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.state.ErrorMessage; got != "Save conflict: the remote vault changed. Reopen it and retry the save." {
t.Fatalf("state.ErrorMessage = %q, want normalized save conflict guidance", got)
}
if got := u.state.StatusMessage; got != "" {
t.Fatalf("state.StatusMessage = %q, want empty after remote save conflict", got)
}
if !u.state.Dirty {
t.Fatal("Dirty = false, want true after remote save conflict")
}
if putCount != 1 {
t.Fatalf("remote PUT count = %d, want 1", putCount)
}
}
func TestUIMasterKeyInputSupportsKeyFileAndCompositeKeys(t *testing.T) {
t.Parallel()
keyFile := filepath.Join(t.TempDir(), "master.key")
keyData := []byte("key-file-bytes")
if err := os.WriteFile(keyFile, keyData, 0o600); err != nil {
t.Fatalf("WriteFile(keyFile) error = %v", err)
}
u := newUIWithSession("desktop", &session.Manager{})
u.setMasterKeyMode(vault.MasterKeyModePasswordAndKeyFile)
u.masterPassword.SetText("correct horse battery staple")
u.keyFilePath.SetText(keyFile)
key, err := u.currentMasterKey()
if err != nil {
t.Fatalf("currentMasterKey() error = %v", err)
}
if key.Password != "correct horse battery staple" {
t.Fatalf("MasterKey.Password = %q, want correct horse battery staple", key.Password)
}
if !bytes.Equal(key.KeyFileData, keyData) {
t.Fatalf("MasterKey.KeyFileData = %q, want %q", key.KeyFileData, keyData)
}
}
func TestUISectionNavigationShowsTemplatesAndRecycleBin(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
Templates: []vault.Entry{
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates", "Web"}},
},
RecycleBin: []vault.Entry{
{ID: "deleted-1", Title: "Deleted Entry", Path: []string{"Root", "Internet"}},
},
})
u.showTemplatesSection()
if got := u.filteredTitles(); !slices.Equal(got, []string{"Website Login"}) {
t.Fatalf("template filteredTitles() = %v, want [Website Login]", got)
}
u.showRecycleBinSection()
if got := u.filteredTitles(); !slices.Equal(got, []string{"Deleted Entry"}) {
t.Fatalf("recycle filteredTitles() = %v, want [Deleted Entry]", got)
}
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
if got := u.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) {
t.Fatalf("entry filteredTitles() = %v, want [Vault Console]", got)
}
}
func TestUIGroupManagementAndPathNavigationAreControllerDriven(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
{ID: "entry-2", Title: "Home Assistant", Path: []string{"Root", "Home Assistant"}},
},
})
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root"})
u.filter()
u.groupName.SetText("Finance")
if err := u.createGroupAction(); err != nil {
t.Fatalf("createGroupAction() error = %v", err)
}
if got := u.childGroups(); !slices.Equal(got, []string{"Finance", "Home Assistant", "Internet"}) {
t.Fatalf("childGroups() after create = %v, want [Finance Home Assistant Internet]", got)
}
u.state.EnterGroup("Finance")
u.filter()
u.groupName.SetText("Budget")
if err := u.renameGroupAction(); err != nil {
t.Fatalf("renameGroupAction() error = %v", err)
}
if !slices.Equal(u.state.CurrentPath, []string{"Root", "Budget"}) {
t.Fatalf("state.CurrentPath after rename = %v, want [Root Budget]", u.state.CurrentPath)
}
u.state.NavigateToPath([]string{"Root"})
u.filter()
if got := u.childGroups(); !slices.Equal(got, []string{"Budget", "Home Assistant", "Internet"}) {
t.Fatalf("childGroups() after rename = %v, want [Budget Home Assistant Internet]", got)
}
u.state.NavigateToPath([]string{"Root", "Budget"})
u.filter()
u.armDeleteCurrentGroupAction()
if err := u.deleteCurrentGroupAction(); err != nil {
t.Fatalf("deleteCurrentGroupAction() error = %v", err)
}
if !slices.Equal(u.state.CurrentPath, []string{"Root"}) {
t.Fatalf("state.CurrentPath after delete = %v, want [Root]", u.state.CurrentPath)
}
if got := u.childGroups(); !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
t.Fatalf("childGroups() after delete = %v, want [Home Assistant Internet]", got)
}
}
func TestUIGroupControlsCanBeCollapsed(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{})
u.showEntriesSection()
if u.groupControlsHidden {
t.Fatal("groupControlsHidden = true, want false by default")
}
u.groupControlsHidden = true
if !u.groupControlsHidden {
t.Fatal("groupControlsHidden = false, want true after collapsing")
}
u.groupControlsHidden = false
if u.groupControlsHidden {
t.Fatal("groupControlsHidden = true, want false after expanding")
}
}
func TestUISavingEntryWithDifferentPathMovesItBetweenGroups(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
{
ID: "ha",
Title: "Home Assistant",
Username: "rustyryan",
Password: "token-2",
URL: "https://ha.example.test",
Path: []string{"Root", "Home Assistant"},
},
},
})
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
u.state.SelectedEntryID = "vault-console"
u.loadSelectedEntryIntoEditor()
u.entryPath.SetText("Root / Home Assistant")
if err := u.saveEntryAction(); err != nil {
t.Fatalf("saveEntryAction() error = %v", err)
}
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
if got := u.filteredTitles(); len(got) != 0 {
t.Fatalf("filteredTitles() in source group = %v, want empty after move", got)
}
u.state.NavigateToPath([]string{"Root", "Home Assistant"})
u.filter()
if got := u.filteredTitles(); !slices.Equal(got, []string{"Home Assistant", "Vault Console"}) {
t.Fatalf("filteredTitles() in destination group = %v, want [Vault Console Home Assistant]", got)
}
}
func TestUISavesDuplicatesDeletesAndRestoresEntriesFromTheEditor(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
})
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
u.state.SelectedEntryID = "vault-console"
u.loadSelectedEntryIntoEditor()
u.entryPassword.SetText("token-2")
if err := u.saveEntryAction(); err != nil {
t.Fatalf("saveEntryAction() error = %v", err)
}
u.filter()
if entry, ok := u.selectedEntry(); !ok || entry.Password != "token-2" {
t.Fatalf("selectedEntry() = %#v, want updated password token-2", entry)
}
if err := u.duplicateSelectedEntryAction(); err != nil {
t.Fatalf("duplicateSelectedEntryAction() error = %v", err)
}
u.filter()
if got := u.filteredTitles(); !slices.Equal(got, []string{"Vault Console", "Vault Console (Copy)"}) {
t.Fatalf("filteredTitles() after duplicate = %v, want copy present", got)
}
if err := u.deleteSelectedEntryAction(); err != nil {
t.Fatalf("deleteSelectedEntryAction() error = %v", err)
}
u.showRecycleBinSection()
if got := u.filteredTitles(); !slices.Equal(got, []string{"Vault Console (Copy)"}) {
t.Fatalf("recycle filteredTitles() = %v, want deleted copy", got)
}
u.state.SelectedEntryID = "vault-console-copy"
if err := u.restoreSelectedRecycleEntryAction(); err != nil {
t.Fatalf("restoreSelectedRecycleEntryAction() error = %v", err)
}
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
if got := u.filteredTitles(); !slices.Equal(got, []string{"Vault Console", "Vault Console (Copy)"}) {
t.Fatalf("filteredTitles() after restore = %v, want restored copy", got)
}
}
func TestUICreatesEntryWithAllSupportedEditorFields(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{})
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
u.entryID.SetText("bellagio")
u.entryTitle.SetText("Bellagio")
u.entryUsername.SetText("rustyryan")
u.entryPassword.SetText("token-1")
u.entryURL.SetText("https://bellagio.example.invalid")
u.entryNotes.SetText("Registrar account")
u.entryTags.SetText("dns, registrar")
u.entryPath.SetText("Root / Internet")
u.setCustomFieldRows(map[string]string{
"Environment": "prod",
"Account ID": "12345",
})
if err := u.saveEntryAction(); err != nil {
t.Fatalf("saveEntryAction() create error = %v", err)
}
u.filter()
if got := u.filteredTitles(); !slices.Equal(got, []string{"Bellagio"}) {
t.Fatalf("filteredTitles() = %v, want [Bellagio]", got)
}
item, ok := u.selectedEntry()
if !ok {
t.Fatal("selectedEntry() ok = false, want created entry")
}
if item.Title != "Bellagio" || item.Username != "rustyryan" || item.Password != "token-1" || item.URL != "https://bellagio.example.invalid" {
t.Fatalf("selectedEntry() = %#v, want created Bellagio credentials", item)
}
if item.Notes != "Registrar account" {
t.Fatalf("selectedEntry().Notes = %q, want %q", item.Notes, "Registrar account")
}
if !slices.Equal(item.Tags, []string{"dns", "registrar"}) {
t.Fatalf("selectedEntry().Tags = %v, want [dns registrar]", item.Tags)
}
if item.Fields["Environment"] != "prod" || item.Fields["Account ID"] != "12345" {
t.Fatalf("selectedEntry().Fields = %#v, want parsed custom fields", item.Fields)
}
}
func TestUILoadSelectedEntryIntoEditorPopulatesStructuredCustomFields(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "gitlab",
Title: "Gitlab",
Path: []string{"Root", "Internet"},
Fields: map[string]string{
"AndroidApp1": "androidapp://com.gitlab.android",
"OTP": "123456",
},
},
},
})
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
u.state.SelectedEntryID = "gitlab"
u.loadSelectedEntryIntoEditor()
if len(u.customFieldKeys) != 2 || len(u.customFieldValues) != 2 {
t.Fatalf("custom field rows = %d/%d, want 2 rows", len(u.customFieldKeys), len(u.customFieldValues))
}
got := map[string]string{}
for i := range u.customFieldKeys {
got[u.customFieldKeys[i].Text()] = u.customFieldValues[i].Text()
}
if got["AndroidApp1"] != "androidapp://com.gitlab.android" || got["OTP"] != "123456" {
t.Fatalf("custom field rows = %#v, want AndroidApp1 and OTP values", got)
}
}
func TestUIEditingEntryPathMovesEntryBetweenGroups(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
})
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
u.state.SelectedEntryID = "vault-console"
u.loadSelectedEntryIntoEditor()
u.entryPath.SetText("Root / Infrastructure")
if err := u.saveEntryAction(); err != nil {
t.Fatalf("saveEntryAction() move error = %v", err)
}
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
if got := u.filteredTitles(); len(got) != 0 {
t.Fatalf("filteredTitles() in old path = %v, want empty after move", got)
}
u.state.NavigateToPath([]string{"Root", "Infrastructure"})
u.filter()
if got := u.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) {
t.Fatalf("filteredTitles() in new path = %v, want [Vault Console]", got)
}
u.state.SelectedEntryID = "vault-console"
model, err := u.state.Session.Current()
if err != nil {
t.Fatalf("state.Session.Current() error = %v", err)
}
var (
item vault.Entry
ok bool
)
for _, candidate := range model.Entries {
if candidate.ID == "vault-console" {
item = candidate
ok = true
break
}
}
if !ok {
t.Fatal("model.Entries contains vault-console = false, want moved entry")
}
if !slices.Equal(item.Path, []string{"Root", "Infrastructure"}) {
t.Fatalf("model.Entries vault-console Path = %v, want [Root Infrastructure]", item.Path)
}
}
func TestUITemplateAndAttachmentActionsWorkThroughEditor(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Templates: []vault.Entry{
{
ID: "tpl-1",
Title: "Website Login",
Username: "template-user",
Password: "template-password",
Notes: "Reusable template",
Path: []string{"Templates", "Web"},
},
},
})
u.showTemplatesSection()
u.filter()
u.state.SelectedEntryID = "tpl-1"
u.loadSelectedEntryIntoEditor()
u.entryTitle.SetText("Website Login Updated")
if err := u.saveTemplateAction(); err != nil {
t.Fatalf("saveTemplateAction() error = %v", err)
}
u.entryID.SetText("entry-1")
u.entryTitle.SetText("Bellagio")
u.entryUsername.SetText("rustyryan")
u.entryPassword.SetText("token-1")
u.entryURL.SetText("https://bellagio.example.invalid")
u.entryPath.SetText("Root / Internet")
if err := u.instantiateSelectedTemplateAction(); err != nil {
t.Fatalf("instantiateSelectedTemplateAction() error = %v", err)
}
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
u.state.SelectedEntryID = "entry-1"
u.loadSelectedEntryIntoEditor()
attachmentPath := filepath.Join(t.TempDir(), "token.txt")
attachmentExportPath := filepath.Join(t.TempDir(), "exported.txt")
content := []byte("attachment-content")
if err := os.WriteFile(attachmentPath, content, 0o600); err != nil {
t.Fatalf("WriteFile(attachmentPath) error = %v", err)
}
u.attachmentPath.SetText(attachmentPath)
u.attachmentName.SetText("token.txt")
if err := u.addAttachmentAction(); err != nil {
t.Fatalf("addAttachmentAction() error = %v", err)
}
if got := u.selectedAttachmentNames(); !slices.Equal(got, []string{"token.txt"}) {
t.Fatalf("selectedAttachmentNames() = %v, want [token.txt]", got)
}
replacementPath := filepath.Join(t.TempDir(), "token-replacement.txt")
replacement := []byte("attachment-replacement")
if err := os.WriteFile(replacementPath, replacement, 0o600); err != nil {
t.Fatalf("WriteFile(replacementPath) error = %v", err)
}
u.attachmentPath.SetText(replacementPath)
if err := u.replaceAttachmentAction(); err != nil {
t.Fatalf("replaceAttachmentAction() error = %v", err)
}
u.exportAttachmentPath.SetText(attachmentExportPath)
if err := u.exportAttachmentAction(); err != nil {
t.Fatalf("exportAttachmentAction() error = %v", err)
}
exported, err := os.ReadFile(attachmentExportPath)
if err != nil {
t.Fatalf("ReadFile(exportAttachmentPath) error = %v", err)
}
if !bytes.Equal(exported, replacement) {
t.Fatalf("exported attachment = %q, want %q", exported, replacement)
}
if err := u.removeAttachmentAction(); err != nil {
t.Fatalf("removeAttachmentAction() error = %v", err)
}
u.showTemplatesSection()
u.filter()
u.state.SelectedEntryID = "tpl-1"
if err := u.deleteSelectedTemplateAction(); err != nil {
t.Fatalf("deleteSelectedTemplateAction() error = %v", err)
}
u.filter()
if got := u.filteredTitles(); len(got) != 0 {
t.Fatalf("template filteredTitles() after delete = %v, want empty", got)
}
}
func TestUITemplatesCanBeBrowsedCreatedEditedDeletedAndInstantiated(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Templates: []vault.Entry{
{
ID: "tpl-existing",
Title: "SSH Login",
Username: "root",
Password: "template-password",
Path: []string{"Templates", "Infra"},
},
},
})
u.showTemplatesSection()
if got := u.childGroups(); !slices.Equal(got, []string{"Infra"}) {
t.Fatalf("childGroups() = %v, want [Infra] at template root", got)
}
u.state.NavigateToPath([]string{"Templates", "Infra"})
u.filter()
if got := u.filteredTitles(); !slices.Equal(got, []string{"SSH Login"}) {
t.Fatalf("filteredTitles() = %v, want [SSH Login] in template path", got)
}
u.state.SelectedEntryID = ""
u.loadSelectedEntryIntoEditor()
u.entryID.SetText("tpl-web")
u.entryTitle.SetText("Website Login")
u.entryUsername.SetText("template-user")
u.entryPassword.SetText("template-password")
u.entryNotes.SetText("Reusable template for website accounts.")
u.entryTags.SetText("template, web")
u.entryPath.SetText("Templates / Web")
if err := u.saveTemplateAction(); err != nil {
t.Fatalf("saveTemplateAction(create) error = %v", err)
}
u.state.NavigateToPath([]string{"Templates", "Web"})
u.filter()
if got := u.filteredTitles(); !slices.Equal(got, []string{"Website Login"}) {
t.Fatalf("filteredTitles() after create = %v, want [Website Login]", got)
}
u.state.SelectedEntryID = "tpl-web"
u.loadSelectedEntryIntoEditor()
u.entryTitle.SetText("Website Login Updated")
u.setCustomFieldRows(map[string]string{"Environment": "prod"})
if err := u.saveTemplateAction(); err != nil {
t.Fatalf("saveTemplateAction(edit) error = %v", err)
}
u.filter()
selected, ok := u.selectedEntry()
if !ok {
t.Fatal("selectedEntry() ok = false, want updated template")
}
if selected.Title != "Website Login Updated" {
t.Fatalf("selectedEntry().Title = %q, want %q", selected.Title, "Website Login Updated")
}
if selected.Fields["Environment"] != "prod" {
t.Fatalf("selectedEntry().Fields[Environment] = %q, want %q", selected.Fields["Environment"], "prod")
}
u.entryID.SetText("entry-1")
u.entryTitle.SetText("Bellagio")
u.entryUsername.SetText("rustyryan")
u.entryPassword.SetText("token-1")
u.entryURL.SetText("https://bellagio.example.invalid")
u.entryPath.SetText("Root / Internet")
if err := u.instantiateSelectedTemplateAction(); err != nil {
t.Fatalf("instantiateSelectedTemplateAction() error = %v", err)
}
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
u.state.SelectedEntryID = "entry-1"
instantiated, ok := u.selectedEntry()
if !ok {
t.Fatal("selectedEntry() ok = false, want instantiated entry")
}
if instantiated.Title != "Bellagio" {
t.Fatalf("selectedEntry().Title = %q, want %q", instantiated.Title, "Bellagio")
}
if instantiated.Notes != "Reusable template for website accounts." {
t.Fatalf("selectedEntry().Notes = %q, want template notes", instantiated.Notes)
}
if instantiated.Fields["Environment"] != "prod" {
t.Fatalf("selectedEntry().Fields[Environment] = %q, want %q", instantiated.Fields["Environment"], "prod")
}
u.showTemplatesSection()
u.state.NavigateToPath([]string{"Templates", "Web"})
u.filter()
u.state.SelectedEntryID = "tpl-web"
if err := u.deleteSelectedTemplateAction(); err != nil {
t.Fatalf("deleteSelectedTemplateAction() error = %v", err)
}
u.filter()
if got := u.filteredTitles(); len(got) != 0 {
t.Fatalf("filteredTitles() after delete = %v, want empty", got)
}
}
func TestUIAttachmentActionsRejectDuplicateMissingAndOversizeCases(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Attachments: map[string][]byte{"token.txt": []byte("original")},
Path: []string{"Root", "Internet"},
},
},
})
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
u.state.SelectedEntryID = "vault-console"
u.loadSelectedEntryIntoEditor()
addPath := filepath.Join(t.TempDir(), "token.txt")
if err := os.WriteFile(addPath, []byte("duplicate"), 0o600); err != nil {
t.Fatalf("WriteFile(addPath) error = %v", err)
}
u.attachmentName.SetText("token.txt")
u.attachmentPath.SetText(addPath)
if err := u.addAttachmentAction(); err == nil || !strings.Contains(err.Error(), "already exists") {
t.Fatalf("addAttachmentAction() error = %v, want duplicate-name failure", err)
}
u.attachmentName.SetText("missing.txt")
if err := u.replaceAttachmentAction(); err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("replaceAttachmentAction() error = %v, want missing-attachment failure", err)
}
oversizePath := filepath.Join(t.TempDir(), "oversize.bin")
oversizeContent := bytes.Repeat([]byte("a"), maxAttachmentBytes+1)
if err := os.WriteFile(oversizePath, oversizeContent, 0o600); err != nil {
t.Fatalf("WriteFile(oversizePath) error = %v", err)
}
u.attachmentName.SetText("oversize.bin")
u.attachmentPath.SetText(oversizePath)
if err := u.addAttachmentAction(); err == nil || !strings.Contains(err.Error(), "too large") {
t.Fatalf("addAttachmentAction() oversize error = %v, want size failure", err)
}
}
func TestUIRestoresSelectedEntryHistoryVersion(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-2",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
History: []vault.Entry{
{
ID: "vault-console-h1",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
},
},
})
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
u.state.SelectedEntryID = "vault-console"
u.loadSelectedEntryIntoEditor()
u.historyIndex.SetText("0")
if err := u.restoreSelectedHistoryAction(); err != nil {
t.Fatalf("restoreSelectedHistoryAction() error = %v", err)
}
u.filter()
if entry, ok := u.selectedEntry(); !ok || entry.Password != "token-1" {
t.Fatalf("selectedEntry() = %#v, want restored password token-1", entry)
}
}
func TestUISelectingEntryHistoryVersionTracksSelectedVersion(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-2",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
History: []vault.Entry{
{
ID: "vault-console-h1",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
Notes: "previous token",
},
{
ID: "vault-console-h0",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-0",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
Notes: "oldest token",
},
},
},
},
})
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
u.state.SelectedEntryID = "vault-console"
u.loadSelectedEntryIntoEditor()
history := u.visibleHistory()
if len(history) != 2 {
t.Fatalf("len(visibleHistory()) = %d, want 2", len(history))
}
if history[1].Notes != "oldest token" {
t.Fatalf("visibleHistory()[1].Notes = %q, want %q", history[1].Notes, "oldest token")
}
if err := u.selectHistoryVersion(1); err != nil {
t.Fatalf("selectHistoryVersion(1) error = %v", err)
}
selected, ok := u.selectedHistoryEntry()
if !ok {
t.Fatal("selectedHistoryEntry() ok = false, want true")
}
if selected.Password != "token-0" {
t.Fatalf("selectedHistoryEntry().Password = %q, want %q", selected.Password, "token-0")
}
}
func TestUIKeyboardShortcutActionsDispatchExpectedCommands(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
})
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
u.state.SelectedEntryID = "vault-console"
u.loadSelectedEntryIntoEditor()
if err := u.performShortcut(shortcutNewEntry); err != nil {
t.Fatalf("performShortcut(new-entry) error = %v", err)
}
if u.state.SelectedEntryID != "" {
t.Fatalf("SelectedEntryID = %q, want empty after new-entry shortcut", u.state.SelectedEntryID)
}
u.state.SelectedEntryID = "vault-console"
if err := u.performShortcut(shortcutCopyUser); err != nil {
t.Fatalf("performShortcut(copy-user) error = %v", err)
}
if err := u.performShortcut(shortcutCopyPassword); err != nil {
t.Fatalf("performShortcut(copy-password) error = %v", err)
}
if err := u.performShortcut(shortcutCopyURL); err != nil {
t.Fatalf("performShortcut(copy-url) error = %v", err)
}
}
func TestUIKeyboardNavigationMovesAcrossBreadcrumbsListAndDetail(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "bellagio",
Title: "Bellagio",
Username: "rustyryan",
Path: []string{"Root", "Internet"},
},
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Path: []string{"Root", "Internet"},
},
},
})
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
if got := u.keyboardFocus; got != focusSearch {
t.Fatalf("keyboardFocus = %q, want %q", got, focusSearch)
}
u.handleKeyPress(key.NameTab, 0)
if got := u.keyboardFocus; got != breadcrumbFocusID(0) {
t.Fatalf("keyboardFocus after Tab = %q, want %q", got, breadcrumbFocusID(0))
}
u.handleKeyPress(key.NameTab, 0)
if got := u.keyboardFocus; got != listFocusID(0) {
t.Fatalf("keyboardFocus after second Tab = %q, want %q", got, listFocusID(0))
}
if got := u.state.SelectedEntryID; got != "bellagio" {
t.Fatalf("SelectedEntryID after list focus = %q, want %q", got, "bellagio")
}
u.handleKeyPress(key.NameDownArrow, 0)
if got := u.keyboardFocus; got != listFocusID(1) {
t.Fatalf("keyboardFocus after Down = %q, want %q", got, listFocusID(1))
}
if got := u.state.SelectedEntryID; got != "vault-console" {
t.Fatalf("SelectedEntryID after Down = %q, want %q", got, "vault-console")
}
u.handleKeyPress(key.NameTab, 0)
if got := u.keyboardFocus; got != detailFocusID(detailFieldTitle) {
t.Fatalf("keyboardFocus after detail Tab = %q, want %q", got, detailFocusID(detailFieldTitle))
}
}
func TestUIKeyboardNavigationActivatesBreadcrumbs(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Path: []string{"Root", "Internet"},
},
},
})
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
u.keyboardFocus = breadcrumbFocusID(0)
u.handleKeyPress(key.NameRightArrow, 0)
if got := u.keyboardFocus; got != breadcrumbFocusID(1) {
t.Fatalf("keyboardFocus after Right = %q, want %q", got, breadcrumbFocusID(1))
}
u.handleKeyPress(key.NameReturn, 0)
if got := u.state.CurrentPath; !slices.Equal(got, []string{"Root"}) {
t.Fatalf("state.CurrentPath after breadcrumb activation = %v, want [Root]", got)
}
}
func TestUIKeyboardShortcutsMoveFocusForSearchAndNewEntry(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
})
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
u.state.SelectedEntryID = "vault-console"
u.loadSelectedEntryIntoEditor()
u.keyboardFocus = listFocusID(0)
u.handleKeyPress("F", key.ModShortcut)
if got := u.keyboardFocus; got != focusSearch {
t.Fatalf("keyboardFocus after shortcut search = %q, want %q", got, focusSearch)
}
u.handleKeyPress("N", key.ModShortcut)
if got := u.state.SelectedEntryID; got != "" {
t.Fatalf("SelectedEntryID after shortcut new-entry = %q, want empty", got)
}
if got := u.keyboardFocus; got != detailFocusID(detailFieldTitle) {
t.Fatalf("keyboardFocus after shortcut new-entry = %q, want %q", got, detailFocusID(detailFieldTitle))
}
}
func TestUIAccessibilityLabelsDescribeFocusableControls(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Path: []string{"Root", "Internet"},
},
},
})
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
if got := u.accessibilityLabel(focusSearch); got != "Search vault" {
t.Fatalf("accessibilityLabel(search) = %q, want %q", got, "Search vault")
}
if got := u.accessibilityLabel(breadcrumbFocusID(1)); got != "Navigate to Root" {
t.Fatalf("accessibilityLabel(breadcrumb) = %q, want %q", got, "Navigate to Root")
}
if got := u.accessibilityLabel(listFocusID(0)); got != "Select entry Vault Console" {
t.Fatalf("accessibilityLabel(list) = %q, want %q", got, "Select entry Vault Console")
}
if got := u.accessibilityLabel(detailFocusID(detailFieldPassword)); got != "Edit Password" {
t.Fatalf("accessibilityLabel(detail password) = %q, want %q", got, "Edit Password")
}
}
func TestFieldFocusAppearanceScalesForHighDPI(t *testing.T) {
t.Parallel()
lo := fieldFocusAppearance(unit.Metric{PxPerDp: 1, PxPerSp: 1}, true)
hi := fieldFocusAppearance(unit.Metric{PxPerDp: 2.5, PxPerSp: 2.5}, true)
unfocused := fieldFocusAppearance(unit.Metric{PxPerDp: 1, PxPerSp: 1}, false)
if got := lo.MinHeight; got != 44 {
t.Fatalf("fieldFocusAppearance(low).MinHeight = %d, want 44", got)
}
if got := hi.MinHeight; got != 110 {
t.Fatalf("fieldFocusAppearance(high).MinHeight = %d, want 110", got)
}
if got := lo.OutlineWidth; got < 2 {
t.Fatalf("fieldFocusAppearance(low).OutlineWidth = %d, want >= 2", got)
}
if hi.OutlineWidth <= lo.OutlineWidth {
t.Fatalf("fieldFocusAppearance(high).OutlineWidth = %d, want > %d", hi.OutlineWidth, lo.OutlineWidth)
}
if lo.OutlineColor == unfocused.OutlineColor {
t.Fatalf("fieldFocusAppearance().OutlineColor focused = %#v, want distinct from unfocused %#v", lo.OutlineColor, unfocused.OutlineColor)
}
}
func TestUIActionErrorsAndStatusMessagesAreCapturedForDisplay(t *testing.T) {
t.Parallel()
u := newUIWithSession("desktop", &session.Manager{})
u.vaultPath.SetText("/does/not/exist.kdbx")
u.masterPassword.SetText("correct horse battery staple")
u.runAction("open vault", u.openVaultAction)
if u.state.ErrorMessage == "" {
t.Fatal("state.ErrorMessage = empty, want visible action error")
}
u = newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Username: "dannyocean", Password: "token-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}},
},
})
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
u.state.SelectedEntryID = "vault-console"
u.runAction("copy username", func() error { return u.copySelectedFieldAction(clipboard.TargetUsername) })
if u.state.StatusMessage == "" {
t.Fatal("state.StatusMessage = empty, want visible success status")
}
}
func TestUIPasswordProfilesAreVisibleInEntryWorkflow(t *testing.T) {
t.Parallel()
u := newUIWithSession("desktop", &session.Manager{})
got := u.passwordProfileOptionsText()
for _, want := range passwords.DefaultProfileNames() {
if !strings.Contains(got, want) {
t.Fatalf("passwordProfileOptionsText() = %q, want profile %q to be visible", got, want)
}
}
}
func TestUIGeneratedPasswordFlowsIntoCreateEntryForm(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)
}
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
u.loadSelectedEntryIntoEditor()
u.entryID.SetText("entry-1")
u.entryTitle.SetText("Generated Entry")
u.entryUsername.SetText("rustyryan")
u.entryURL.SetText("https://vault.crew.example.invalid")
u.entryPath.SetText("Root / Internet")
u.passwordProfile.SetText("memorable")
if err := u.generatePasswordAction(); err != nil {
t.Fatalf("generatePasswordAction() error = %v", err)
}
generated := u.entryPassword.Text()
if len(generated) < passwords.DefaultProfiles()["memorable"].Length {
t.Fatalf("len(entryPassword.Text()) = %d, want at least %d after generate", len(generated), passwords.DefaultProfiles()["memorable"].Length)
}
if err := u.saveEntryAction(); err != nil {
t.Fatalf("saveEntryAction() error = %v", err)
}
u.state.SelectedEntryID = "entry-1"
saved, ok := u.selectedEntry()
if !ok {
t.Fatal("selectedEntry() ok = false, want true for saved generated entry")
}
if saved.Password != generated {
t.Fatalf("saved.Password = %q, want generated password %q", saved.Password, generated)
}
}
func TestUIBannerSurfacePrefersLoadingThenErrorThenStatus(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{})
u.loadingMessage = "Opening vault..."
if got := u.bannerSurface(); got.Kind != bannerLoading || got.Message != "Opening vault..." {
t.Fatalf("bannerSurface() with loading = %#v, want loading banner", got)
}
u.loadingMessage = ""
u.state.ErrorMessage = "save failed"
if got := u.bannerSurface(); got.Kind != bannerError || got.Message != "save failed" {
t.Fatalf("bannerSurface() with error = %#v, want error banner", got)
}
u.state.ErrorMessage = ""
u.state.StatusMessage = "save complete"
if got := u.bannerSurface(); got.Kind != bannerStatus || got.Message != "save complete" {
t.Fatalf("bannerSurface() with status = %#v, want status banner", got)
}
}
func TestUIStatusBannerExpiresAfterTimeout(t *testing.T) {
t.Parallel()
now := time.Date(2026, time.March, 29, 12, 0, 0, 0, time.UTC)
u := newUIWithModel("desktop", vault.Model{})
u.now = func() time.Time { return now }
u.state.StatusMessage = "synchronize vault complete"
u.statusExpiresAt = now.Add(statusBannerDuration)
if got := u.bannerSurface(); got.Kind != bannerStatus || got.Message != "synchronize vault complete" {
t.Fatalf("bannerSurface() before expiry = %#v, want visible status banner", got)
}
now = now.Add(statusBannerDuration + time.Millisecond)
if got := u.bannerSurface(); got.Kind != bannerNone {
t.Fatalf("bannerSurface() after expiry = %#v, want no banner", got)
}
if got := u.state.StatusMessage; got != "" {
t.Fatalf("state.StatusMessage after expiry = %q, want empty", got)
}
}
func TestUIRunActionNormalizesRemoteSaveConflictsForDisplay(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{})
u.runAction("save vault", func() error {
return errors.New("save remote vaults/main.kdbx: " + webdav.ErrConflict.Error())
})
if got := u.state.ErrorMessage; got != "Save conflict: the remote vault changed. Reopen it and retry the save." {
t.Fatalf("state.ErrorMessage = %q, want normalized save conflict guidance", got)
}
if got := u.state.StatusMessage; got != "" {
t.Fatalf("state.StatusMessage = %q, want empty on conflict", got)
}
}
func TestUIUsesKeePassGOProductCopy(t *testing.T) {
t.Parallel()
if productName != "KeePassGO" {
t.Fatalf("productName = %q, want %q", productName, "KeePassGO")
}
}
func TestUIShowsLifecycleSetupOnlyBeforeVaultIsOpened(t *testing.T) {
t.Parallel()
u := newUIWithSession("desktop", &session.Manager{})
if !u.shouldShowLifecycleSetup() {
t.Fatal("shouldShowLifecycleSetup() = false, want true before opening a vault")
}
u.masterPassword.SetText("correct horse battery staple")
if err := u.createVaultAction(); err != nil {
t.Fatalf("createVaultAction() error = %v", err)
}
if u.shouldShowLifecycleSetup() {
t.Fatal("shouldShowLifecycleSetup() = true, want false after opening a vault")
}
}
func TestUIRequiresExplicitEditModeForEntryEditor(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{{ID: "1", Title: "Vault Console", Path: []string{"Root"}}},
})
u.filter()
u.state.SelectedEntryID = "1"
if u.editingEntry {
t.Fatal("editingEntry = true, want false by default")
}
u.editingEntry = true
u.loadSelectedEntryIntoEditor()
if !u.editingEntry {
t.Fatal("editingEntry = false, want true after entering edit mode")
}
}
func TestUIAutoEntersSingleVaultRootGroupAndDisplaysSlashRoot(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "keepass.kdbx")
var encoded bytes.Buffer
if err := vault.SaveKDBX(&encoded, vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"keepass", "Crew", "Internet"}},
},
}, "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(keepass.kdbx) error = %v", err)
}
u := newUIWithSession("desktop", &session.Manager{})
u.masterPassword.SetText("correct horse battery staple")
u.vaultPath.SetText(path)
if err := u.openVaultAction(); err != nil {
t.Fatalf("openVaultAction() error = %v", err)
}
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) {
t.Fatalf("currentPath = %v, want [keepass]", got)
}
if got := u.displayPath(); len(got) != 0 {
t.Fatalf("displayPath() = %v, want root slash path", got)
}
if got := u.childGroups(); !slices.Equal(got, []string{"Crew"}) {
t.Fatalf("childGroups() = %v, want [Crew]", got)
}
}
func TestUINoteRecentVaultDeduplicatesAndOrdersMostRecentFirst(t *testing.T) {
t.Parallel()
u := newUIWithSession("desktop", &session.Manager{})
u.recentVaultsPath = filepath.Join(t.TempDir(), "recent-vaults.json")
u.recentVaults = nil
u.noteRecentVault("/tmp/one.kdbx")
u.noteRecentVault("/tmp/two.kdbx")
u.noteRecentVault("/tmp/one.kdbx")
if got := u.recentVaults; !slices.Equal(got, []string{"/tmp/one.kdbx", "/tmp/two.kdbx"}) {
t.Fatalf("recentVaults = %v, want [/tmp/one.kdbx /tmp/two.kdbx]", got)
}
}
func TestUILoadsRecentVaultsFromPersistedConfig(t *testing.T) {
t.Parallel()
configPath := filepath.Join(t.TempDir(), "recent-vaults.json")
first := newUIWithSession("desktop", &session.Manager{})
first.recentVaultsPath = configPath
first.recentVaults = nil
first.noteRecentVault("/tmp/one.kdbx")
first.noteRecentVault("/tmp/two.kdbx")
second := newUIWithSession("desktop", &session.Manager{})
second.recentVaultsPath = configPath
second.recentVaults = nil
second.loadRecentVaults()
if got := second.recentVaults; !slices.Equal(got, []string{"/tmp/two.kdbx", "/tmp/one.kdbx"}) {
t.Fatalf("recentVaults after reload = %v, want [/tmp/two.kdbx /tmp/one.kdbx]", got)
}
}
func TestUIRecentVaultsPersistLastOpenedGroupPerVault(t *testing.T) {
t.Parallel()
configPath := filepath.Join(t.TempDir(), "recent-vaults.json")
first := newUIWithSession("desktop", &session.Manager{})
first.recentVaultsPath = configPath
first.recentVaults = nil
first.currentPath = []string{"Root", "Internet"}
first.syncedPath = []string{"Root", "Internet"}
first.noteRecentVault("/tmp/one.kdbx")
first.currentPath = []string{"Root", "Home Assistant"}
first.syncedPath = []string{"Root", "Home Assistant"}
first.noteRecentVault("/tmp/two.kdbx")
first.currentPath = []string{"Root", "Finance"}
first.syncedPath = []string{"Root", "Finance"}
first.noteRecentVault("/tmp/one.kdbx")
second := newUIWithSession("desktop", &session.Manager{})
second.recentVaultsPath = configPath
second.recentVaults = nil
second.loadRecentVaults()
if got := second.recentVaults; !slices.Equal(got, []string{"/tmp/one.kdbx", "/tmp/two.kdbx"}) {
t.Fatalf("recentVaults after reload = %v, want [/tmp/one.kdbx /tmp/two.kdbx]", got)
}
if got := second.recentVaultGroup("/tmp/one.kdbx"); !slices.Equal(got, []string{"Root", "Finance"}) {
t.Fatalf("recentVaultGroup(one) = %v, want [Root Finance]", got)
}
if got := second.recentVaultGroup("/tmp/two.kdbx"); !slices.Equal(got, []string{"Root", "Home Assistant"}) {
t.Fatalf("recentVaultGroup(two) = %v, want [Root Home Assistant]", got)
}
}
func TestUIOpenVaultRestoresLastOpenedGroupForThatVault(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "keepass.kdbx")
statePath := filepath.Join(dir, "recent-vaults.json")
u := newUIWithSession("desktop", &session.Manager{})
u.recentVaultsPath = statePath
u.masterPassword.SetText("correct horse battery staple")
if err := u.createVaultAction(); err != nil {
t.Fatalf("createVaultAction() error = %v", err)
}
if err := u.state.UpsertEntry(vault.Entry{
ID: "entry-1",
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.state.NavigateToPath([]string{"Root", "Internet"})
u.currentPath = []string{"Root", "Internet"}
u.syncedPath = []string{"Root", "Internet"}
u.saveAsPath.SetText(path)
if err := u.saveAsAction(); err != nil {
t.Fatalf("saveAsAction() error = %v", err)
}
reopened := newUIWithSession("desktop", &session.Manager{})
reopened.recentVaultsPath = statePath
reopened.recentVaults = nil
reopened.loadRecentVaults()
reopened.masterPassword.SetText("correct horse battery staple")
reopened.vaultPath.SetText(path)
if err := reopened.openVaultAction(); err != nil {
t.Fatalf("openVaultAction() error = %v", err)
}
if got := reopened.displayPath(); !slices.Equal(got, []string{"Internet"}) {
t.Fatalf("displayPath() after reopen = %v, want [Internet]", got)
}
if got := reopened.state.CurrentPath; !slices.Equal(got, []string{"Root", "Internet"}) {
t.Fatalf("state.CurrentPath after reopen = %v, want [Root Internet]", got)
}
}
func TestUIRecentRemoteConnectionsPersistAndReload(t *testing.T) {
t.Parallel()
configPath := filepath.Join(t.TempDir(), "recent-remotes.json")
first := newUIWithSession("desktop", &session.Manager{})
first.recentRemotesPath = configPath
first.recentRemotes = nil
first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx", "alice", "secret-1", true)
first.noteRecentRemote("https://dav.example.com", "vaults/team.kdbx", "bob", "secret-2", false)
first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx", "alice", "secret-3", true)
second := newUIWithSession("desktop", &session.Manager{})
second.recentRemotesPath = configPath
second.recentRemotes = nil
second.loadRecentRemotes()
if got := len(second.recentRemotes); got != 2 {
t.Fatalf("len(recentRemotes) = %d, want 2", got)
}
if got := second.recentRemotes[0]; got.BaseURL != "https://dav.example.com" || got.Path != "vaults/home.kdbx" || got.Username != "alice" || got.Password != "secret-3" {
t.Fatalf("recentRemotes[0] = %#v, want updated remembered credentials", got)
}
if got := second.recentRemotes[1]; got.Username != "" || got.Password != "" {
t.Fatalf("recentRemotes[1] = %#v, want credentials omitted when remember disabled", got)
}
}
func TestDefaultStatePathsUsesProvidedStateDir(t *testing.T) {
t.Parallel()
base := filepath.Join(t.TempDir(), "keepassgo-state")
paths := defaultStatePaths(base)
if got := paths.DefaultSaveAsPath; got != filepath.Join(base, "vault.kdbx") {
t.Fatalf("DefaultSaveAsPath = %q, want %q", got, filepath.Join(base, "vault.kdbx"))
}
if got := paths.RecentVaultsPath; got != filepath.Join(base, "recent-vaults.json") {
t.Fatalf("RecentVaultsPath = %q, want %q", got, filepath.Join(base, "recent-vaults.json"))
}
if got := paths.RecentRemotesPath; got != filepath.Join(base, "recent-remotes.json") {
t.Fatalf("RecentRemotesPath = %q, want %q", got, filepath.Join(base, "recent-remotes.json"))
}
}
func TestDefaultStatePathsUsesEnvironmentStateDirWhenFlagUnset(t *testing.T) {
base := filepath.Join(t.TempDir(), "keepassgo-state-env")
t.Setenv("KEEPASSGO_STATE_DIR", base)
paths := defaultStatePaths("")
if got := paths.DefaultSaveAsPath; got != filepath.Join(base, "vault.kdbx") {
t.Fatalf("DefaultSaveAsPath = %q, want %q", got, filepath.Join(base, "vault.kdbx"))
}
if got := paths.RecentVaultsPath; got != filepath.Join(base, "recent-vaults.json") {
t.Fatalf("RecentVaultsPath = %q, want %q", got, filepath.Join(base, "recent-vaults.json"))
}
if got := paths.RecentRemotesPath; got != filepath.Join(base, "recent-remotes.json") {
t.Fatalf("RecentRemotesPath = %q, want %q", got, filepath.Join(base, "recent-remotes.json"))
}
}
func TestResolveFlagOrEnvPrefersFlagThenEnvThenFallback(t *testing.T) {
t.Setenv("KEEPASSGO_TEST_VALUE", "from-env")
if got := resolveFlagOrEnv("from-flag", "KEEPASSGO_TEST_VALUE", "fallback"); got != "from-flag" {
t.Fatalf("resolveFlagOrEnv(flag) = %q, want %q", got, "from-flag")
}
if got := resolveFlagOrEnv("", "KEEPASSGO_TEST_VALUE", "fallback"); got != "from-env" {
t.Fatalf("resolveFlagOrEnv(env) = %q, want %q", got, "from-env")
}
if got := resolveFlagOrEnv("", "KEEPASSGO_TEST_MISSING", "fallback"); got != "fallback" {
t.Fatalf("resolveFlagOrEnv(fallback) = %q, want %q", got, "fallback")
}
}
func TestEnterOnLocalLifecycleScreenDefaultsToOpenVault(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.kdbx) error = %v", err)
}
u := newUIWithSession("desktop", &session.Manager{})
u.masterPassword.SetText("correct horse battery staple")
u.vaultPath.SetText(path)
handled := u.handleKeyPress(key.NameReturn, 0)
if !handled {
t.Fatal("handleKeyPress(Return) = false, want true")
}
if got := u.state.StatusMessage; got != "" {
t.Fatalf("StatusMessage = %q, want empty after open", got)
}
}
func TestEnterOnRemoteLifecycleScreenDefaultsToOpenRemoteVault(t *testing.T) {
t.Parallel()
masterKey := vault.MasterKey{Password: "correct horse battery staple"}
var encoded bytes.Buffer
if err := vault.SaveKDBXWithKey(&encoded, vault.Model{}, masterKey); err != nil {
t.Fatalf("SaveKDBXWithKey() error = %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Fatalf("unexpected method = %s, want GET", r.Method)
}
_, _ = w.Write(encoded.Bytes())
}))
defer server.Close()
u := newUIWithSession("desktop", &session.Manager{})
u.lifecycleMode = "remote"
u.masterPassword.SetText("correct horse battery staple")
u.remoteBaseURL.SetText(server.URL)
u.remotePath.SetText("vault.kdbx")
handled := u.handleKeyPress(key.NameReturn, 0)
if !handled {
t.Fatal("handleKeyPress(Return) = false, want true")
}
if got := u.state.StatusMessage; got != "" {
t.Fatalf("StatusMessage = %q, want empty after remote open", got)
}
}
func TestMasterPasswordPeekResetsAfterOpeningVault(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "vault.kdbx")
var encoded bytes.Buffer
if err := vault.SaveKDBX(&encoded, vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Password: "token-1", Path: []string{"Root", "Internet"}},
},
}, "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.kdbx) error = %v", err)
}
u := newUIWithSession("desktop", &session.Manager{})
u.masterPassword.SetText("correct horse battery staple")
u.vaultPath.SetText(path)
u.showPassword = true
if err := u.openVaultAction(); err != nil {
t.Fatalf("openVaultAction() error = %v", err)
}
if u.showPassword {
t.Fatal("showPassword = true after openVaultAction(), want false")
}
}
func TestPasswordPeekResetsWhenChangingSelectedEntry(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Password: "token-1", Path: []string{"Root", "Internet"}},
{ID: "bellagio", Title: "Bellagio", Password: "token-2", Path: []string{"Root", "Internet"}},
},
})
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
u.state.SelectedEntryID = "vault-console"
u.loadSelectedEntryIntoEditor()
u.showPassword = true
u.state.SelectedEntryID = "bellagio"
u.loadSelectedEntryIntoEditor()
if u.showPassword {
t.Fatal("showPassword = true after selecting a different entry, want false")
}
}
func TestEnterOnLockedScreenDefaultsToUnlockVault(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("correct horse battery staple")
handled := u.handleKeyPress(key.NameReturn, 0)
if !handled {
t.Fatal("handleKeyPress(Return) = false, want true while locked")
}
if u.isVaultLocked() {
t.Fatal("isVaultLocked() = true, want false after unlock")
}
if got := u.masterPassword.Text(); got != "" {
t.Fatalf("masterPassword after unlock = %q, want empty", got)
}
}
func TestUILockedVaultUsesSingleUnlockPaneAndOmitsSearchFocus(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)
}
if !u.shouldUseLockedSinglePane() {
t.Fatal("shouldUseLockedSinglePane() = false, want true while locked")
}
if got := u.focusOrder(); !slices.Equal(got, []focusID{detailFocusID(detailFieldPassword)}) {
t.Fatalf("focusOrder() while locked = %v, want only unlock password focus", got)
}
}
func TestUICopyActionsWriteExpectedClipboardContentsAndSanitizedFeedback(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
}
tests := []struct {
name string
target clipboard.Target
label string
want string
}{
{name: "username", target: clipboard.TargetUsername, label: "copy username", want: "dannyocean"},
{name: "password", target: clipboard.TargetPassword, label: "copy password", want: "token-1"},
{name: "url", target: clipboard.TargetURL, label: "copy URL", want: "https://vault.crew.example.invalid"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
u := newUIWithModel("desktop", model)
writer := &memoryClipboardWriter{}
u.clipboardWriter = writer
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
u.state.SelectedEntryID = "vault-console"
u.runAction(tt.label, func() error { return u.copySelectedFieldAction(tt.target) })
if writer.content != tt.want {
t.Fatalf("clipboard content = %q, want %q", writer.content, tt.want)
}
if u.state.StatusMessage != tt.label+" complete" {
t.Fatalf("state.StatusMessage = %q, want %q", u.state.StatusMessage, tt.label+" complete")
}
if u.state.ErrorMessage != "" {
t.Fatalf("state.ErrorMessage = %q, want empty", u.state.ErrorMessage)
}
if strings.Contains(u.state.StatusMessage, tt.want) {
t.Fatalf("state.StatusMessage = %q, must not contain copied secret or field value %q", u.state.StatusMessage, tt.want)
}
})
}
}
func TestUICopyActionSanitizesClipboardBackendErrors(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
})
u.clipboardWriter = failingClipboardWriter{err: os.ErrPermission}
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
u.state.SelectedEntryID = "vault-console"
u.runAction("copy password", func() error { return u.copySelectedFieldAction(clipboard.TargetPassword) })
if u.state.ErrorMessage != clipboard.ErrWriteFailed.Error() {
t.Fatalf("state.ErrorMessage = %q, want %q", u.state.ErrorMessage, clipboard.ErrWriteFailed.Error())
}
if strings.Contains(u.state.ErrorMessage, "token-1") {
t.Fatalf("state.ErrorMessage = %q, must not contain copied password", u.state.ErrorMessage)
}
if u.state.StatusMessage != "" {
t.Fatalf("state.StatusMessage = %q, want empty on copy failure", u.state.StatusMessage)
}
}
func TestUIGeneratedPasswordFlowsIntoEditEntryForm(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
})
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
u.state.SelectedEntryID = "vault-console"
u.loadSelectedEntryIntoEditor()
u.passwordProfile.SetText("strong")
if err := u.generatePasswordAction(); err != nil {
t.Fatalf("generatePasswordAction() error = %v", err)
}
generated := u.entryPassword.Text()
if generated == "token-1" {
t.Fatal("entryPassword.Text() = token-1, want a newly generated password")
}
if len(generated) < passwords.DefaultProfiles()["strong"].Length {
t.Fatalf("len(entryPassword.Text()) = %d, want at least %d after generate", len(generated), passwords.DefaultProfiles()["strong"].Length)
}
if err := u.saveEntryAction(); err != nil {
t.Fatalf("saveEntryAction() error = %v", err)
}
saved, ok := u.selectedEntry()
if !ok {
t.Fatal("selectedEntry() ok = false, want true for edited entry")
}
if saved.Password != generated {
t.Fatalf("saved.Password = %q, want generated password %q", saved.Password, generated)
}
}
func TestUIPasswordRevealTogglesDisplayedPasswordAndLockResetsIt(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
Path: []string{"Root", "Internet"},
},
},
})
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
u.state.SelectedEntryID = "vault-console"
if got := u.detailPasswordValue(); got != "••••••••" {
t.Fatalf("detailPasswordValue() hidden = %q, want %q", got, "••••••••")
}
u.showPassword = true
if got := u.detailPasswordValue(); got != "token-1" {
t.Fatalf("detailPasswordValue() revealed = %q, want %q", got, "token-1")
}
if err := u.lockAction(); err != nil {
t.Fatalf("lockAction() error = %v", err)
}
if u.showPassword {
t.Fatal("showPassword = true after lockAction(), want false")
}
}
type memoryClipboardWriter struct {
content string
}
func (w *memoryClipboardWriter) WriteText(text string) error {
w.content = text
return nil
}
type failingClipboardWriter struct {
err error
}
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.state.StatusMessage; got != "create vault complete" {
t.Fatalf("status after create = %q, want %q", got, "create vault complete")
}
if got := u.state.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.state.StatusMessage; got != "save-as vault complete" {
t.Fatalf("status after save-as = %q, want %q", got, "save-as vault complete")
}
if got := u.state.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.state.StatusMessage; got != "save vault complete" {
t.Fatalf("status after save = %q, want %q", got, "save vault complete")
}
if got := u.state.ErrorMessage; got != "" {
t.Fatalf("error after save = %q, want empty", got)
}
u.runAction("lock vault", u.lockAction)
if got := u.state.StatusMessage; got != "lock vault complete" {
t.Fatalf("status after lock = %q, want %q", got, "lock vault complete")
}
if got := u.state.ErrorMessage; got != "" {
t.Fatalf("error after lock = %q, want empty", got)
}
u.masterPassword.SetText("correct horse battery staple")
u.runAction("unlock vault", u.unlockAction)
if got := u.state.StatusMessage; got != "unlock vault complete" {
t.Fatalf("status after unlock = %q, want %q", got, "unlock vault complete")
}
if got := u.state.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.state.StatusMessage; got != "" {
t.Fatalf("status after open = %q, want empty", got)
}
if got := reopened.state.ErrorMessage; got != "" {
t.Fatalf("error after open = %q, want empty", got)
}
}
func TestUIGroupDeletionOnlyAllowsEmptyGroupsAndRequiresConfirmation(t *testing.T) {
t.Parallel()
t.Run("non-empty group cannot be deleted", func(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
Groups: [][]string{{"Root"}, {"Root", "Internet"}},
})
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
if deletable, reason := u.currentGroupDeletionState(); deletable {
t.Fatal("currentGroupDeletionState() deletable = true, want false for non-empty group")
} else if !strings.Contains(reason, "contains entries") {
t.Fatalf("currentGroupDeletionState() reason = %q, want contains entries guidance", reason)
}
})
t.Run("empty group requires confirmation before deletion", func(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Groups: [][]string{{"Root"}, {"Root", "Archive"}},
})
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Archive"})
u.filter()
if deletable, reason := u.currentGroupDeletionState(); !deletable {
t.Fatalf("currentGroupDeletionState() = false, want true for empty group: %q", reason)
}
u.armDeleteCurrentGroupAction()
if !u.deleteGroupPendingConfirmation() {
t.Fatal("deleteGroupPendingConfirmation() = false, want true after arming delete")
}
if got := u.state.StatusMessage; !strings.Contains(got, "Confirm deleting empty group") {
t.Fatalf("StatusMessage after arming delete = %q, want confirmation guidance", got)
}
if err := u.deleteCurrentGroupAction(); err != nil {
t.Fatalf("deleteCurrentGroupAction() error = %v", err)
}
if u.deleteGroupPendingConfirmation() {
t.Fatal("deleteGroupPendingConfirmation() = true, want false after deletion")
}
if got := u.currentPath; !slices.Equal(got, []string{"Root"}) {
t.Fatalf("currentPath after delete = %v, want [Root]", got)
}
if got := u.childGroups(); len(got) != 0 {
t.Fatalf("childGroups() after delete = %v, want empty", got)
}
})
}
func TestUITemplateSectionEmptyStateStaysProductSpecific(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{})
u.showTemplatesSection()
got := u.listEmptyMessage()
if got != "Templates are not available in this build." {
t.Fatalf("listEmptyMessage() = %q, want templates unavailable copy", 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.state.StatusMessage; got != "save vault complete" {
t.Fatalf("status after save = %q, want %q", got, "save vault complete")
}
if got := u.state.ErrorMessage; got != "" {
t.Fatalf("error after save = %q, want empty", got)
}
})
t.Run("save-as uses default target path", func(t *testing.T) {
t.Parallel()
u := newUIWithSession("desktop", &session.Manager{})
u.masterPassword.SetText("correct horse battery staple")
u.defaultSaveAsPath = filepath.Join(t.TempDir(), "default-save-as.kdbx")
u.runAction("create vault", u.createVaultAction)
u.runAction("save-as vault", u.saveAsAction)
if got := u.state.StatusMessage; got != "save-as vault complete" {
t.Fatalf("status after save-as = %q, want %q", got, "save-as vault complete")
}
if got := u.state.ErrorMessage; got != "" {
t.Fatalf("error after save-as = %q, want empty", got)
}
if _, err := os.Stat(u.defaultSaveAsPath); err != nil {
t.Fatalf("Stat(defaultSaveAsPath) error = %v", err)
}
})
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.state.StatusMessage; got != "" {
t.Fatalf("status after failed open = %q, want empty", got)
}
if got := u.state.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.state.StatusMessage; got != "" {
t.Fatalf("status after unreadable open = %q, want empty", got)
}
if got := u.state.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.state.StatusMessage; got != "" {
t.Fatalf("status after decode failure = %q, want empty", got)
}
if got := u.state.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.state.StatusMessage; got != "" {
t.Fatalf("status after invalid master open = %q, want empty", got)
}
if got := u.state.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.state.StatusMessage; got != "" {
t.Fatalf("status after invalid unlock = %q, want empty", got)
}
if got := u.state.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.state.ErrorMessage == "" {
t.Fatal("error after failed save = empty, want visible failure")
}
u.runAction("create vault", u.createVaultAction)
if got := u.state.ErrorMessage; got != "" {
t.Fatalf("error after create = %q, want cleared", got)
}
if got := u.state.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)
}
}