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/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 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.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.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.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.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 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.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 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.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 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.statusMessage; got != "create vault complete" { t.Fatalf("status after create = %q, want %q", got, "create vault complete") } if got := u.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.statusMessage; got != "save-as vault complete" { t.Fatalf("status after save-as = %q, want %q", got, "save-as vault complete") } if got := u.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.statusMessage; got != "save vault complete" { t.Fatalf("status after save = %q, want %q", got, "save vault complete") } if got := u.errorMessage; got != "" { t.Fatalf("error after save = %q, want empty", got) } u.runAction("lock vault", u.lockAction) if got := u.statusMessage; got != "lock vault complete" { t.Fatalf("status after lock = %q, want %q", got, "lock vault complete") } if got := u.errorMessage; got != "" { t.Fatalf("error after lock = %q, want empty", got) } u.runAction("unlock vault", u.unlockAction) if got := u.statusMessage; got != "unlock vault complete" { t.Fatalf("status after unlock = %q, want %q", got, "unlock vault complete") } if got := u.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.statusMessage; got != "open vault complete" { t.Fatalf("status after open = %q, want %q", got, "open vault complete") } if got := reopened.errorMessage; got != "" { t.Fatalf("error after open = %q, want empty", 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.statusMessage; got != "" { t.Fatalf("status after failed save = %q, want empty", got) } if got := u.errorMessage; !strings.Contains(got, session.ErrNoPath.Error()) { t.Fatalf("error after failed save = %q, want %q", got, session.ErrNoPath.Error()) } }) t.Run("save-as without target 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-as vault", u.saveAsAction) if got := u.statusMessage; got != "" { t.Fatalf("status after failed save-as = %q, want empty", got) } if got := u.errorMessage; got != "save-as path is required" { t.Fatalf("error after failed save-as = %q, want %q", got, "save-as path is required") } }) 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.statusMessage; got != "" { t.Fatalf("status after failed open = %q, want empty", got) } if got := u.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.statusMessage; got != "" { t.Fatalf("status after unreadable open = %q, want empty", got) } if got := u.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.statusMessage; got != "" { t.Fatalf("status after decode failure = %q, want empty", got) } if got := u.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.statusMessage; got != "" { t.Fatalf("status after invalid master open = %q, want empty", got) } if got := u.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.statusMessage; got != "" { t.Fatalf("status after invalid unlock = %q, want empty", got) } if got := u.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.errorMessage == "" { t.Fatal("error after failed save = empty, want visible failure") } u.runAction("create vault", u.createVaultAction) if got := u.errorMessage; got != "" { t.Fatalf("error after create = %q, want cleared", got) } if got := u.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) } }