1759 lines
54 KiB
Go
1759 lines
54 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gioui.org/io/key"
|
|
"gioui.org/unit"
|
|
|
|
"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: "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.currentPath = []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.currentPath = []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.currentPath = []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 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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
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 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)
|
|
}
|
|
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.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.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
|
|
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: "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.errorMessage; !strings.Contains(got, "open remote vault failed:") {
|
|
t.Fatalf("errorMessage = %q, want open remote vault failure", got)
|
|
}
|
|
if got := u.statusMessage; got != "" {
|
|
t.Fatalf("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.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 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()
|
|
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 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.currentPath = []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.entryFields.SetText("Environment=prod\nAccount 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 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.currentPath = []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.currentPath = []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.currentPath = []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)
|
|
}
|
|
|
|
item, ok := u.selectedEntry()
|
|
if !ok {
|
|
t.Fatal("selectedEntry() ok = false, want moved entry")
|
|
}
|
|
if !slices.Equal(item.Path, []string{"Root", "Infrastructure"}) {
|
|
t.Fatalf("selectedEntry().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)
|
|
}
|
|
|
|
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 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.entryFields.SetText("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 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()
|
|
|
|
history := u.visibleHistory()
|
|
if len(history) != 1 {
|
|
t.Fatalf("len(visibleHistory()) = %d, want 1", len(history))
|
|
}
|
|
if history[0].Password != "token-1" {
|
|
t.Fatalf("visibleHistory()[0].Password = %q, want %q", history[0].Password, "token-1")
|
|
}
|
|
|
|
if err := u.selectHistoryVersion(0); err != nil {
|
|
t.Fatalf("selectHistoryVersion(0) error = %v", err)
|
|
}
|
|
if got := u.historyIndex.Text(); got != "0" {
|
|
t.Fatalf("historyIndex.Text() = %q, want %q", got, "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.errorMessage == "" {
|
|
t.Fatal("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.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("bellagio")
|
|
u.filter()
|
|
if msg := u.listEmptyMessage(); msg != `No entries match "bellagio". 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: "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.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: "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.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: "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
|
|
}
|