Files
keepassgo/main_test.go
T
2026-03-29 13:34:25 -07:00

1037 lines
31 KiB
Go

package main
import (
"bytes"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"git.julianfamily.org/keepassgo/clipboard"
"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: "Dynadot", Username: "jjulian", URL: "https://www.dynadot.com", Path: []string{"Joe", "Internet"}},
{ID: "2", Title: "Git Server", Username: "joejulian", URL: "https://git.julianfamily.org", Path: []string{"Joe", "Internet"}},
{ID: "3", Title: "Home Assistant (Codex)", Username: "codex", URL: "https://lights.julianfamily.org", Path: []string{"Joe", "Home Assistant"}},
},
})
u.currentPath = []string{"Joe", "Internet"}
u.filter()
if got := u.filteredTitles(); !slices.Equal(got, []string{"Dynadot", "Git Server"}) {
t.Fatalf("filteredTitles() = %v, want [Dynadot Git Server]", got)
}
u.search.SetText("lights")
u.filter()
if got := u.filteredTitles(); !slices.Equal(got, []string{"Home Assistant (Codex)"}) {
t.Fatalf("search filteredTitles() = %v, want [Home Assistant (Codex)]", got)
}
}
func TestUIChildGroupsComeFromVaultModel(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{ID: "1", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
{ID: "2", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}},
{ID: "3", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}},
},
})
u.currentPath = []string{"Joe"}
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: "Dynadot", Path: []string{"Joe", "Internet"}},
{ID: "2", Title: "Git Server", Path: []string{"Joe", "Internet"}},
},
})
u.currentPath = []string{"Joe", "Internet"}
u.filter()
u.state.SelectedEntryID = "2"
got, ok := u.selectedEntry()
if !ok {
t.Fatal("selectedEntry() ok = false, want true")
}
if got.Title != "Git Server" {
t.Fatalf("selectedEntry().Title = %q, want %q", got.Title, "Git Server")
}
}
func TestUILockHidesVisibleEntries(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{ID: "1", Title: "Dynadot", Path: []string{"Joe", "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 err := u.state.UpsertEntry(vault.Entry{
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://git.julianfamily.org",
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.currentPath = []string{"Root", "Internet"}
u.filter()
if got := u.filteredTitles(); len(got) != 0 {
t.Fatalf("filteredTitles() = %v, want empty while locked", got)
}
if err := u.unlockAction(); err != nil {
t.Fatalf("unlockAction() error = %v", err)
}
u.filter()
if got := u.filteredTitles(); !slices.Equal(got, []string{"Git Server"}) {
t.Fatalf("filteredTitles() after unlock = %v, want [Git Server]", 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)
}
reopened.currentPath = []string{"Root", "Internet"}
reopened.filter()
if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Git Server"}) {
t.Fatalf("reopened filteredTitles() = %v, want [Git Server]", 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: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://git.julianfamily.org",
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)
}
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.currentPath = []string{"Root", "Internet"}
reopened.filter()
if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Git Server"}) {
t.Fatalf("reopened filteredTitles() = %v, want [Git Server]", 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: "git-server",
Title: "Git Server",
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.errorMessage == "" {
t.Fatal("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.currentPath = []string{"Root", "Internet"}
reopened.filter()
if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Git Server"}) {
t.Fatalf("reopened filteredTitles() = %v, want [Git Server]", got)
}
}
func TestUIMasterKeyValidationErrorsAreVisible(t *testing.T) {
t.Parallel()
tests := []struct {
name string
mode vault.MasterKeyMode
password string
keyFile string
wantError string
}{
{
name: "password mode requires password",
mode: vault.MasterKeyModePasswordOnly,
wantError: "master password is required",
},
{
name: "key file mode requires path",
mode: vault.MasterKeyModeKeyFileOnly,
wantError: "key file is required",
},
{
name: "composite mode requires password",
mode: vault.MasterKeyModePasswordAndKeyFile,
keyFile: filepath.Join("/tmp", "ignored.key"),
wantError: "master password is required",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
u := newUIWithSession("desktop", &session.Manager{})
u.setMasterKeyMode(tt.mode)
u.masterPassword.SetText(tt.password)
u.keyFilePath.SetText(tt.keyFile)
u.runAction("create vault", u.createVaultAction)
if got := u.errorMessage; got != tt.wantError {
t.Fatalf("errorMessage = %q, want %q", got, tt.wantError)
}
if got := u.statusMessage; got != "" {
t.Fatalf("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.errorMessage; got == "" || got[:14] != "read key file:" {
t.Fatalf("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.errorMessage; got == "" || !bytes.Contains([]byte(got), []byte(vault.ErrInvalidMasterKey.Error())) {
t.Fatalf("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: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://git.julianfamily.org",
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: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "token-2",
URL: "https://git.julianfamily.org",
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 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: "Git Server", 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.currentPath = []string{"Root", "Internet"}
u.filter()
if got := u.filteredTitles(); !slices.Equal(got, []string{"Git Server"}) {
t.Fatalf("entry filteredTitles() = %v, want [Git Server]", got)
}
}
func TestUISavesDuplicatesDeletesAndRestoresEntriesFromTheEditor(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
})
u.showEntriesSection()
u.currentPath = []string{"Root", "Internet"}
u.filter()
u.state.SelectedEntryID = "git-server"
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{"Git Server", "Git Server (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{"Git Server (Copy)"}) {
t.Fatalf("recycle filteredTitles() = %v, want deleted copy", got)
}
u.state.SelectedEntryID = "git-server-copy"
if err := u.restoreSelectedRecycleEntryAction(); err != nil {
t.Fatalf("restoreSelectedRecycleEntryAction() error = %v", err)
}
u.showEntriesSection()
u.currentPath = []string{"Root", "Internet"}
u.filter()
if got := u.filteredTitles(); !slices.Equal(got, []string{"Git Server", "Git Server (Copy)"}) {
t.Fatalf("filteredTitles() after restore = %v, want restored copy", got)
}
}
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("Dynadot")
u.entryUsername.SetText("jjulian")
u.entryPassword.SetText("token-1")
u.entryURL.SetText("https://www.dynadot.com")
u.entryPath.SetText("Root / Internet")
if err := u.instantiateSelectedTemplateAction(); err != nil {
t.Fatalf("instantiateSelectedTemplateAction() error = %v", err)
}
u.showEntriesSection()
u.currentPath = []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)
}
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, content) {
t.Fatalf("exported attachment = %q, want %q", exported, content)
}
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 TestUIRestoresSelectedEntryHistoryVersion(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "token-2",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
History: []vault.Entry{
{
ID: "git-server-h1",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
},
},
})
u.showEntriesSection()
u.currentPath = []string{"Root", "Internet"}
u.filter()
u.state.SelectedEntryID = "git-server"
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 TestUIKeyboardShortcutActionsDispatchExpectedCommands(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
})
u.showEntriesSection()
u.currentPath = []string{"Root", "Internet"}
u.filter()
u.state.SelectedEntryID = "git-server"
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 = "git-server"
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 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.errorMessage == "" {
t.Fatal("errorMessage = empty, want visible action error")
}
u = newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Username: "joejulian", Password: "token-1", URL: "https://git.julianfamily.org", Path: []string{"Root", "Internet"}},
},
})
u.showEntriesSection()
u.currentPath = []string{"Root", "Internet"}
u.filter()
u.state.SelectedEntryID = "git-server"
u.runAction("copy username", func() error { return u.copySelectedFieldAction(clipboard.TargetUsername) })
if u.statusMessage == "" {
t.Fatal("statusMessage = empty, want visible success status")
}
}
func TestUILockSurfacePromptsForMasterKeyMaterial(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)
}
got := u.sessionSurface()
if !got.Locked {
t.Fatal("sessionSurface().Locked = false, want true")
}
if got.Title != "Vault locked" {
t.Fatalf("sessionSurface().Title = %q, want %q", got.Title, "Vault locked")
}
if got.Message != "Enter a master password, choose a key file, or provide both to unlock the vault." {
t.Fatalf("sessionSurface().Message = %q, want unlock prompt", got.Message)
}
if msg := u.listEmptyMessage(); msg != "Unlock the vault to browse entries and groups." {
t.Fatalf("listEmptyMessage() = %q, want locked list prompt", msg)
}
if msg := u.detailPlaceholderMessage(); msg != "Unlock the vault to inspect entries, attachments, and history." {
t.Fatalf("detailPlaceholderMessage() = %q, want locked detail prompt", msg)
}
}
func TestUIEmptyStatesExplainCurrentSectionAndSearch(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{})
if msg := u.listEmptyMessage(); msg != "Create or open a vault, then add an entry to get started." {
t.Fatalf("listEmptyMessage() = %q, want empty entries guidance", msg)
}
u.search.SetText("dynadot")
u.filter()
if msg := u.listEmptyMessage(); msg != `No entries match "dynadot". Clear or refine the search.` {
t.Fatalf("search listEmptyMessage() = %q, want search guidance", msg)
}
u.search.SetText("")
u.showTemplatesSection()
if msg := u.listEmptyMessage(); msg != "No templates yet. Save a reusable entry as a template." {
t.Fatalf("template listEmptyMessage() = %q, want template empty guidance", msg)
}
u.showRecycleBinSection()
if msg := u.listEmptyMessage(); msg != "Recycle Bin is empty." {
t.Fatalf("recycle listEmptyMessage() = %q, want recycle empty guidance", msg)
}
}
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.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.errorMessage = ""
u.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 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.errorMessage; got != "Save conflict: the remote vault changed. Reopen it and retry the save." {
t.Fatalf("errorMessage = %q, want normalized save conflict guidance", got)
}
if got := u.statusMessage; got != "" {
t.Fatalf("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")
}
if desktopSubtitle != "KeePass-compatible password management for desktop-first workflows" {
t.Fatalf("desktopSubtitle = %q, want updated product subtitle", desktopSubtitle)
}
}
func TestUICopyActionsWriteExpectedClipboardContentsAndSanitizedFeedback(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
}
tests := []struct {
name string
target clipboard.Target
label string
want string
}{
{name: "username", target: clipboard.TargetUsername, label: "copy username", want: "joejulian"},
{name: "password", target: clipboard.TargetPassword, label: "copy password", want: "token-1"},
{name: "url", target: clipboard.TargetURL, label: "copy URL", want: "https://git.julianfamily.org"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
u := newUIWithModel("desktop", model)
writer := &memoryClipboardWriter{}
u.clipboardWriter = writer
u.showEntriesSection()
u.currentPath = []string{"Root", "Internet"}
u.filter()
u.state.SelectedEntryID = "git-server"
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.statusMessage != tt.label+" complete" {
t.Fatalf("statusMessage = %q, want %q", u.statusMessage, tt.label+" complete")
}
if u.errorMessage != "" {
t.Fatalf("errorMessage = %q, want empty", u.errorMessage)
}
if strings.Contains(u.statusMessage, tt.want) {
t.Fatalf("statusMessage = %q, must not contain copied secret or field value %q", u.statusMessage, tt.want)
}
})
}
}
func TestUICopyActionSanitizesClipboardBackendErrors(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
})
u.clipboardWriter = failingClipboardWriter{err: os.ErrPermission}
u.showEntriesSection()
u.currentPath = []string{"Root", "Internet"}
u.filter()
u.state.SelectedEntryID = "git-server"
u.runAction("copy password", func() error { return u.copySelectedFieldAction(clipboard.TargetPassword) })
if u.errorMessage != clipboard.ErrWriteFailed.Error() {
t.Fatalf("errorMessage = %q, want %q", u.errorMessage, clipboard.ErrWriteFailed.Error())
}
if strings.Contains(u.errorMessage, "token-1") {
t.Fatalf("errorMessage = %q, must not contain copied password", u.errorMessage)
}
if u.statusMessage != "" {
t.Fatalf("statusMessage = %q, want empty on copy failure", u.statusMessage)
}
}
func TestUIPasswordRevealTogglesDisplayedPasswordAndLockResetsIt(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
Path: []string{"Root", "Internet"},
},
},
})
u.showEntriesSection()
u.currentPath = []string{"Root", "Internet"}
u.filter()
u.state.SelectedEntryID = "git-server"
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
}