package main import ( "bytes" "encoding/json" "errors" "image" "io" "net/http" "net/http/httptest" "os" "path/filepath" "slices" "strings" "testing" "time" "gioui.org/io/key" "gioui.org/layout" "gioui.org/op" "gioui.org/unit" "gioui.org/widget" "git.julianfamily.org/keepassgo/apiapproval" "git.julianfamily.org/keepassgo/apiaudit" "git.julianfamily.org/keepassgo/apitokens" "git.julianfamily.org/keepassgo/appstate" "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 TestMain(m *testing.M) { stateDir, err := os.MkdirTemp("", "keepassgo-test-state-") if err != nil { panic(err) } if err := os.Setenv("KEEPASSGO_STATE_DIR", stateDir); err != nil { _ = os.RemoveAll(stateDir) panic(err) } code := m.Run() _ = os.RemoveAll(stateDir) os.Exit(code) } func waitForBackgroundResult(t *testing.T, u *ui) backgroundActionResult { t.Helper() select { case result := <-u.backgroundResults: return result case <-time.After(2 * time.Second): t.Fatal("timed out waiting for background action result") return backgroundActionResult{} } } type summarySession struct { model vault.Model hasVault bool locked bool remote bool } func (s summarySession) Current() (vault.Model, error) { if s.locked { return vault.Model{}, session.ErrLocked } return s.model, nil } func (s summarySession) Save(vault.Model) error { return nil } func (s summarySession) SaveAs(string, vault.MasterKey) error { return nil } func (s summarySession) Create(vault.MasterKey) error { return nil } func (s summarySession) Open(string, vault.MasterKey) error { return nil } func (s summarySession) OpenRemote(*webdav.Client, string, vault.MasterKey) error { return nil } func (s summarySession) ChangeMasterKey(vault.MasterKey) error { return nil } func (s summarySession) Lock() error { return nil } func (s summarySession) Unlock(vault.MasterKey) error { return nil } func (s summarySession) HasVault() bool { return s.hasVault } func (s summarySession) IsLocked() bool { return s.locked } func (s summarySession) IsRemote() bool { return s.remote } type remoteOpenCaptureSession struct { model vault.Model remoteClient webdav.Client remotePath string } func (s *remoteOpenCaptureSession) Current() (vault.Model, error) { return s.model, nil } func (s *remoteOpenCaptureSession) OpenRemote(client webdav.Client, path string, _ vault.MasterKey) error { s.remoteClient = client s.remotePath = path return nil } type saveCaptureSession struct { model vault.Model saveCount int saveErr error } func (s *saveCaptureSession) Current() (vault.Model, error) { return s.model, nil } func (s *saveCaptureSession) Save() error { s.saveCount++ return s.saveErr } type captureVaultSharer struct { path string title string err error } func (s *captureVaultSharer) ShareVault(path, title string) error { s.path = path s.title = title return s.err } 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: "bashertarr", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Security Office"}}, }, }) 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 TestUIMasterPasswordUsesPasswordInputHint(t *testing.T) { t.Parallel() u := newUIWithSession("phone", &session.Manager{}) if got := u.masterPassword.InputHint; got != key.HintPassword { t.Fatalf("masterPassword.InputHint = %v, want %v", got, key.HintPassword) } } func TestLocalVaultPathHelpForAndroidUsesChooserLanguage(t *testing.T) { t.Parallel() if got := localVaultPathHelpForRuntime("android"); got != "Choose the existing .kdbx file to open." { t.Fatalf("localVaultPathHelpForRuntime(android) = %q, want chooser guidance", got) } } func TestPickedDocumentNameUsesFileBaseName(t *testing.T) { t.Parallel() dir := t.TempDir() path := filepath.Join(dir, "mint-ledger.kdbx") if err := os.WriteFile(path, []byte("mint"), 0o600); err != nil { t.Fatalf("WriteFile(%q) error = %v", path, err) } file, err := os.Open(path) if err != nil { t.Fatalf("Open(%q) error = %v", path, err) } t.Cleanup(func() { _ = file.Close() }) if got := pickedDocumentName(file, "selected-vault.kdbx"); got != "mint-ledger.kdbx" { t.Fatalf("pickedDocumentName(file, fallback) = %q, want mint-ledger.kdbx", got) } } func TestPickedDocumentNameFallsBackWhenUnnamed(t *testing.T) { t.Parallel() reader := io.NopCloser(strings.NewReader("mint")) if got := pickedDocumentName(reader, "crew-ledger.kdbx"); got != "crew-ledger.kdbx" { t.Fatalf("pickedDocumentName(reader, fallback) = %q, want crew-ledger.kdbx", 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: "Vault Vent", URL: "https://climate.example.com", Path: []string{"Root", "Safe House"}}, }, 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.invalid", Path: []string{"Root", "Internet"}}, {ID: "deleted-2", Title: "Deleted Vault Vent", URL: "https://climate.example.com", Path: []string{"Root", "Safe House"}}, }, }) u.showEntriesSection() u.state.NavigateToPath([]string{"Root", "Internet"}) u.search.SetText("climate") u.filter() if got := u.filteredTitles(); !slices.Equal(got, []string{"Vault Vent"}) { t.Fatalf("entries filteredTitles() = %v, want [Vault Vent]", 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 Vault Vent"}) { t.Fatalf("recycle filteredTitles() = %v, want [Deleted Vault Vent]", got) } if got := u.visiblePathContexts(); !slices.Equal(got, []string{"Recycle Bin / Root / Safe House"}) { t.Fatalf("recycle visiblePathContexts() = %v, want [Recycle Bin / Root / Safe House]", got) } }) } } func TestUIListPanelTopSectionsMatchAcrossDesktopAndPhoneForEntries(t *testing.T) { t.Parallel() desktop := newUIWithModel("desktop", vault.Model{}) desktop.state.Section = appstate.SectionEntries phone := newUIWithModel("phone", vault.Model{}) phone.state.Section = appstate.SectionEntries want := []listPanelTopSection{ listPanelTopSearch, listPanelTopNavigation, listPanelTopPath, listPanelTopGroup, listPanelTopGroupTools, listPanelTopPrimary, } if got := desktop.listPanelTopSections(); !slices.Equal(got, want) { t.Fatalf("desktop.listPanelTopSections() = %v, want %v", got, want) } if got := phone.listPanelTopSections(); !slices.Equal(got, want) { t.Fatalf("phone.listPanelTopSections() = %v, want %v", got, want) } } func TestUINavigationHeaderMatchesAcrossDesktopAndPhoneForEntries(t *testing.T) { t.Parallel() desktop := newUIWithModel("desktop", vault.Model{}) desktop.state.Section = appstate.SectionEntries phone := newUIWithModel("phone", vault.Model{}) phone.state.Section = appstate.SectionEntries if got := desktop.navigationHeaderLabel(); got != "Group Tools" { t.Fatalf("desktop.navigationHeaderLabel() = %q, want %q", got, "Group Tools") } if got := phone.navigationHeaderLabel(); got != "Group Tools" { t.Fatalf("phone.navigationHeaderLabel() = %q, want %q", got, "Group Tools") } } func TestUIGroupBarDoesNotShowExplicitNavigationButtonsAcrossModes(t *testing.T) { t.Parallel() desktop := newUIWithModel("desktop", vault.Model{}) desktop.state.Section = appstate.SectionEntries phone := newUIWithModel("phone", vault.Model{}) phone.state.Section = appstate.SectionEntries if desktop.groupBarShowsExplicitNavigationButtons() { t.Fatal("desktop.groupBarShowsExplicitNavigationButtons() = true, want false") } if phone.groupBarShowsExplicitNavigationButtons() { t.Fatal("phone.groupBarShowsExplicitNavigationButtons() = true, want false") } } func TestUITopRightActionOrderMatchesAcrossModes(t *testing.T) { t.Parallel() desktop := newUIWithSession("desktop", summarySession{hasVault: true}) desktop.state.Section = appstate.SectionEntries phone := newUIWithSession("phone", summarySession{hasVault: true}) phone.state.Section = appstate.SectionEntries want := []string{"Sync", "Lock", "Menu"} if got := desktop.topRightActionOrder(); !slices.Equal(got, want) { t.Fatalf("desktop.topRightActionOrder() = %v, want %v", got, want) } if got := phone.topRightActionOrder(); !slices.Equal(got, want) { t.Fatalf("phone.topRightActionOrder() = %v, want %v", got, want) } } func TestUISyncMenuAnchorsMatchAcrossModes(t *testing.T) { t.Parallel() desktop := newUIWithSession("desktop", summarySession{hasVault: true}) desktop.state.Section = appstate.SectionEntries phone := newUIWithSession("phone", summarySession{hasVault: true}) phone.state.Section = appstate.SectionEntries if !desktop.syncMenuDropsBelowTrigger() || !phone.syncMenuDropsBelowTrigger() { t.Fatal("sync menu should drop below trigger across desktop and phone") } if !desktop.syncMenuRightAlignsToTrigger() || !phone.syncMenuRightAlignsToTrigger() { t.Fatal("sync menu should right-align to trigger across desktop and phone") } } func TestUIMainMenuAnchorsMatchAcrossModes(t *testing.T) { t.Parallel() desktop := newUIWithSession("desktop", summarySession{hasVault: true}) desktop.state.Section = appstate.SectionEntries phone := newUIWithSession("phone", summarySession{hasVault: true}) phone.state.Section = appstate.SectionEntries if !desktop.mainMenuDropsBelowTrigger() || !phone.mainMenuDropsBelowTrigger() { t.Fatal("main menu should drop below trigger across desktop and phone") } if !desktop.mainMenuRightAlignsToTrigger() || !phone.mainMenuRightAlignsToTrigger() { t.Fatal("main menu should right-align to trigger across desktop and phone") } } func TestUIHeaderMenusUseOverlayModelAcrossModes(t *testing.T) { t.Parallel() desktop := newUIWithSession("desktop", summarySession{hasVault: true}) desktop.state.Section = appstate.SectionEntries phone := newUIWithSession("phone", summarySession{hasVault: true}) phone.state.Section = appstate.SectionEntries if !desktop.headerMenusUseOverlayModel() || !phone.headerMenusUseOverlayModel() { t.Fatal("header menus should use the same overlay model across desktop and phone") } } func TestAnchoredMenuXAllowsWiderMenusToExtendLeft(t *testing.T) { t.Parallel() if got := anchoredMenuX(48, 160); got != -112 { t.Fatalf("anchoredMenuX(48, 160) = %d, want -112", got) } if got := anchoredMenuX(160, 48); got != 112 { t.Fatalf("anchoredMenuX(160, 48) = %d, want 112", got) } } func TestAnchoredMenuOriginXClampsToVisibleContainer(t *testing.T) { t.Parallel() if got := anchoredMenuOriginX(360, 312, 360, 140); got != 220 { t.Fatalf("anchoredMenuOriginX should keep a right-aligned menu visible, got %d want 220", got) } if got := anchoredMenuOriginX(360, 0, 44, 160); got != 0 { t.Fatalf("anchoredMenuOriginX should clamp oversized left overflow to zero, got %d want 0", got) } } func TestUICurrentVaultSummary(t *testing.T) { t.Parallel() t.Run("local", func(t *testing.T) { t.Parallel() u := newUIWithSession("phone", summarySession{hasVault: true}) u.vaultPath.SetText("/vaults/bellagio.kdbx") u.recentVaultGroups["/vaults/bellagio.kdbx"] = []string{"Root", "Internet"} got := u.currentVaultSummary() want := vaultSummary{ Title: "bellagio.kdbx", Detail: "/vaults/bellagio.kdbx", Context: "Resume in: Root / Internet", } if got != want { t.Fatalf("currentVaultSummary() = %#v, want %#v", got, want) } }) t.Run("remote", func(t *testing.T) { t.Parallel() u := newUIWithSession("phone", summarySession{hasVault: true, remote: true}) u.remoteBaseURL.SetText("https://dav.example.com") u.remotePath.SetText("vaults/home.kdbx") u.recentRemotes = []recentRemoteRecord{{ BaseURL: "https://dav.example.com", Path: "vaults/home.kdbx", LastGroup: []string{"Root", "Shared"}, }} got := u.currentVaultSummary() want := vaultSummary{ Title: "home.kdbx · dav.example.com", Detail: "https://dav.example.com", Context: "Resume in: Root / Shared", } if got != want { t.Fatalf("currentVaultSummary() = %#v, want %#v", got, want) } }) } 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 TestUIRunBackgroundActionIgnoresDuplicateWhileLoading(t *testing.T) { t.Parallel() u := newUIWithState("desktop", &session.Manager{}, statePaths{ DefaultSaveAsPath: filepath.Join(t.TempDir(), "default-save-path.kdbx"), RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), UIPreferencesPath: filepath.Join(t.TempDir(), "ui-preferences.json"), }) started := make(chan struct{}) release := make(chan struct{}) runs := 0 u.runBackgroundAction("open vault", func() (func() error, error) { runs++ close(started) <-release return func() error { return nil }, nil }) <-started u.runBackgroundAction("open vault", func() (func() error, error) { runs++ return func() error { return nil }, nil }) if runs != 1 { t.Fatalf("background runs = %d, want 1", runs) } if got := u.loadingMessage; got != "Open vault..." { t.Fatalf("loadingMessage = %q, want %q", got, "Open vault...") } close(release) result := waitForBackgroundResult(t, u) u.applyBackgroundResult(result) if got := u.loadingMessage; got != "" { t.Fatalf("loadingMessage after apply = %q, want empty", got) } } func TestUICancelLifecycleBusyStateIgnoresLateResultAndKeepsRetryAvailable(t *testing.T) { t.Parallel() u := newUIWithState("desktop", &session.Manager{}, statePaths{ DefaultSaveAsPath: filepath.Join(t.TempDir(), "default-save-path.kdbx"), RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), UIPreferencesPath: filepath.Join(t.TempDir(), "ui-preferences.json"), }) u.vaultPath.SetText("/tmp/example.kdbx") u.lastLifecycleAction = "open vault" started := make(chan struct{}) release := make(chan struct{}) u.runBackgroundAction("open vault", func() (func() error, error) { close(started) <-release return func() error { u.state.StatusMessage = "should not apply" return nil }, nil }) <-started u.cancelLifecycleBusyState() if got := u.loadingMessage; got != "" { t.Fatalf("loadingMessage after cancel = %q, want empty", got) } if got := u.activeBackgroundAction; got != 0 { t.Fatalf("activeBackgroundAction after cancel = %d, want 0", got) } if !u.requestMasterPassFocus { t.Fatal("requestMasterPassFocus after cancel = false, want true") } close(release) result := waitForBackgroundResult(t, u) u.applyBackgroundResult(result) if got := u.state.StatusMessage; got != "" { t.Fatalf("StatusMessage after stale apply = %q, want empty", got) } if got := u.lastLifecycleAction; got != "open vault" { t.Fatalf("lastLifecycleAction after cancel = %q, want open vault", 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", "Security Office"}}, {ID: "3", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}}, }, }) u.state.NavigateToPath([]string{"Crew"}) if got := u.childGroups(); !slices.Equal(got, []string{"Internet", "Security Office"}) { t.Fatalf("childGroups() = %v, want [Internet Security Office]", got) } } func TestUIAPITokenLifecycleManagement(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}, statePaths{ DefaultSaveAsPath: filepath.Join(t.TempDir(), "vault.kdbx"), RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), }) u.masterPassword.SetText("correct horse battery staple") if err := u.createVaultAction(); err != nil { t.Fatalf("createVaultAction() error = %v", err) } u.showAPITokensSection() u.apiTokenName.SetText("Browser Extension") u.apiTokenClientName.SetText("firefox") u.apiTokenExpiresAt.SetText("2026-04-01T15:04:05Z") if err := u.issueAPITokenAction(); err != nil { t.Fatalf("issueAPITokenAction() error = %v", err) } if strings.TrimSpace(u.apiTokenSecret) == "" { t.Fatal("apiTokenSecret = empty, want one-time secret") } tokens := u.apiTokens() if len(tokens) != 1 { t.Fatalf("len(apiTokens()) = %d, want 1", len(tokens)) } if tokens[0].Name != "Browser Extension" || tokens[0].ClientName != "firefox" { t.Fatalf("issued token = %#v, want Browser Extension/firefox", tokens[0]) } if err := u.rotateAPITokenAction(); err != nil { t.Fatalf("rotateAPITokenAction() error = %v", err) } if strings.TrimSpace(u.apiTokenSecret) == "" { t.Fatal("apiTokenSecret after rotate = empty, want one-time secret") } if err := u.disableAPITokenAction(); err != nil { t.Fatalf("disableAPITokenAction() error = %v", err) } disabled, ok := u.selectedAPIToken() if !ok || !disabled.Disabled { t.Fatalf("selectedAPIToken() = %#v, want disabled token", disabled) } } func TestUIAPITokenPolicyRulesCanBeAddedAndRemoved(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}, statePaths{ DefaultSaveAsPath: filepath.Join(t.TempDir(), "vault.kdbx"), RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), }) u.masterPassword.SetText("correct horse battery staple") if err := u.createVaultAction(); err != nil { t.Fatalf("createVaultAction() error = %v", err) } u.showAPITokensSection() u.apiTokenName.SetText("CLI") u.apiTokenClientName.SetText("grpc-cli") if err := u.issueAPITokenAction(); err != nil { t.Fatalf("issueAPITokenAction() error = %v", err) } u.apiPolicyOperation.SetText(string(apitokens.OperationListEntries)) u.apiPolicyPath.SetText("Root / Internet") u.apiPolicyAllow.Value = true u.apiPolicyGroupScopeW.Value = true if err := u.addAPIPolicyRuleAction(); err != nil { t.Fatalf("addAPIPolicyRuleAction() error = %v", err) } token, ok := u.selectedAPIToken() if !ok || len(token.Policies) != 1 { t.Fatalf("selectedAPIToken().Policies = %#v, want 1 rule", token.Policies) } if token.Policies[0].Resource.Kind != apitokens.ResourceGroup { t.Fatalf("rule kind = %q, want group", token.Policies[0].Resource.Kind) } if err := u.removeAPIPolicyRuleAction(0); err != nil { t.Fatalf("removeAPIPolicyRuleAction() error = %v", err) } token, ok = u.selectedAPIToken() if !ok || len(token.Policies) != 0 { t.Fatalf("selectedAPIToken().Policies after remove = %#v, want empty", token.Policies) } } func TestAPITokenStatusSummary(t *testing.T) { t.Parallel() expiresAt := time.Date(2026, 4, 1, 15, 4, 5, 0, time.UTC) revokedAt := time.Date(2026, 4, 2, 12, 0, 0, 0, time.UTC) tests := []struct { name string token apitokens.Token want string }{ { name: "active non expiring", token: apitokens.Token{}, want: "Active · No expiration · 0 policy rules", }, { name: "disabled expiring single rule", token: apitokens.Token{ Disabled: true, ExpiresAt: &expiresAt, Policies: []apitokens.PolicyRule{{}}, }, want: "Disabled · Expires " + expiresAt.Local().Format(time.RFC3339) + " · 1 policy rule", }, { name: "revoked overrides disabled", token: apitokens.Token{ Disabled: true, RevokedAt: &revokedAt, Policies: []apitokens.PolicyRule{{}, {}}, }, want: "Revoked · No expiration · 2 policy rules", }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() if got := apiTokenStatusSummary(tt.token); got != tt.want { t.Fatalf("apiTokenStatusSummary(%#v) = %q, want %q", tt.token, got, tt.want) } }) } } func TestAPITokenManagementSummaryText(t *testing.T) { t.Parallel() token := apitokens.Token{ Name: "Browser Extension", ClientName: "firefox", } if got := apiTokenManagementTitle(apitokens.Token{}, false); got != "Issue API Token" { t.Fatalf("apiTokenManagementTitle(no selection) = %q, want %q", got, "Issue API Token") } if got := apiTokenManagementTitle(token, true); got != "Browser Extension" { t.Fatalf("apiTokenManagementTitle(%#v) = %q, want %q", token, got, "Browser Extension") } if got := apiTokenManagementSubtitle(apitokens.Token{}, false); got != "Create a scoped gRPC credential, then select it here to inspect identity, lifecycle, and policy rules." { t.Fatalf("apiTokenManagementSubtitle(no selection) = %q, want default management guidance", got) } if got := apiTokenManagementSubtitle(token, true); got != "firefox · Active · No expiration · 0 policy rules" { t.Fatalf("apiTokenManagementSubtitle(%#v) = %q, want %q", token, got, "firefox · Active · No expiration · 0 policy rules") } } func TestPolicyRulePartsFormatsGroupAndEntryResources(t *testing.T) { t.Parallel() groupRule := apitokens.PolicyRule{ Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{ Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}, }, } entryRule := apitokens.PolicyRule{ Effect: apitokens.EffectDeny, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{ Kind: apitokens.ResourceEntry, EntryID: "vault-console", }, } if effect, operation, resource := policyRuleParts(groupRule); effect != "ALLOW" || operation != string(apitokens.OperationListEntries) || resource != "Root / Internet" { t.Fatalf("policyRuleParts(groupRule) = (%q, %q, %q), want (%q, %q, %q)", effect, operation, resource, "ALLOW", apitokens.OperationListEntries, "Root / Internet") } if effect, operation, resource := policyRuleParts(entryRule); effect != "DENY" || operation != string(apitokens.OperationCopyPassword) || resource != "Entry: vault-console" { t.Fatalf("policyRuleParts(entryRule) = (%q, %q, %q), want (%q, %q, %q)", effect, operation, resource, "DENY", apitokens.OperationCopyPassword, "Entry: vault-console") } } func TestUIAPITokenDetailPanelHandlesMissingRemoveClickables(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}, statePaths{ DefaultSaveAsPath: filepath.Join(t.TempDir(), "vault.kdbx"), RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), }) u.masterPassword.SetText("correct horse battery staple") if err := u.createVaultAction(); err != nil { t.Fatalf("createVaultAction() error = %v", err) } u.showAPITokensSection() u.apiTokenName.SetText("CLI") u.apiTokenClientName.SetText("grpc-cli") if err := u.issueAPITokenAction(); err != nil { t.Fatalf("issueAPITokenAction() error = %v", err) } u.apiPolicyOperation.SetText(string(apitokens.OperationListEntries)) u.apiPolicyPath.SetText("Crew / bashertarr") u.apiPolicyAllow.Value = true u.apiPolicyGroupScopeW.Value = true if err := u.addAPIPolicyRuleAction(); err != nil { t.Fatalf("addAPIPolicyRuleAction() error = %v", err) } u.apiPolicyRemoves = nil ops := new(op.Ops) gtx := layout.Context{ Ops: ops, Constraints: layout.Exact(image.Pt(800, 600)), } defer func() { if r := recover(); r != nil { t.Fatalf("apiTokenDetailPanel() panicked: %v", r) } }() _ = u.apiTokenDetailPanel(gtx) } func TestUIAPITokenDetailPanelResizesPolicyRemoveClickablesAcrossTokenSelection(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.showAPITokensSection() u.apiTokenName.SetText("CLI One") u.apiTokenClientName.SetText("grpc-cli-1") if err := u.issueAPITokenAction(); err != nil { t.Fatalf("issueAPITokenAction() error = %v", err) } firstID := u.state.SelectedEntryID u.apiPolicyOperation.SetText(string(apitokens.OperationListEntries)) u.apiPolicyPath.SetText("Crew / bashertarr") u.apiPolicyAllow.Value = true u.apiPolicyGroupScopeW.Value = true if err := u.addAPIPolicyRuleAction(); err != nil { t.Fatalf("addAPIPolicyRuleAction() error = %v", err) } u.apiTokenName.SetText("CLI Two") u.apiTokenClientName.SetText("grpc-cli-2") if err := u.issueAPITokenAction(); err != nil { t.Fatalf("issueAPITokenAction() error = %v", err) } secondID := u.state.SelectedEntryID ops := new(op.Ops) gtx := layout.Context{ Ops: ops, Constraints: layout.Exact(image.Pt(800, 600)), } defer func() { if r := recover(); r != nil { t.Fatalf("apiTokenDetailPanel() panicked after token switch: %v", r) } }() u.state.SelectedEntryID = secondID u.loadSelectedAPITokenIntoEditor() if len(u.apiPolicyRemoves) != 0 { t.Fatalf("len(apiPolicyRemoves) after selecting token without policies = %d, want 0", len(u.apiPolicyRemoves)) } _ = u.apiTokenDetailPanel(gtx) u.state.SelectedEntryID = firstID u.loadSelectedAPITokenIntoEditor() if len(u.apiPolicyRemoves) != 1 { t.Fatalf("len(apiPolicyRemoves) after reselecting token with policy = %d, want 1", len(u.apiPolicyRemoves)) } _ = u.apiTokenDetailPanel(gtx) } func TestUILifecycleScreenWithSelectedRecentVaultDoesNotPanic(t *testing.T) { t.Parallel() dir := t.TempDir() paths := statePaths{ DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), SettingsPath: filepath.Join(dir, "settings.json"), UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), AutofillCachePath: filepath.Join(dir, "autofill-cache.json"), } first := newUIWithSession("phone", &session.Manager{}, paths) first.noteRecentVault("/sdcard/Download/sample-vault.kdbx") u := newUIWithSession("phone", &session.Manager{}, paths) ops := new(op.Ops) gtx := layout.Context{ Ops: ops, Constraints: layout.Exact(image.Pt(1080, 2400)), } defer func() { if r := recover(); r != nil { t.Fatalf("layout() panicked with selected startup vault: %v", r) } }() _ = u.layout(gtx) } func TestUILifecycleControlsWithSelectedRecentVaultDoesNotPanic(t *testing.T) { t.Parallel() dir := t.TempDir() paths := statePaths{ DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), SettingsPath: filepath.Join(dir, "settings.json"), UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), AutofillCachePath: filepath.Join(dir, "autofill-cache.json"), } first := newUIWithSession("phone", &session.Manager{}, paths) first.noteRecentVault("/sdcard/Download/sample-vault.kdbx") u := newUIWithSession("phone", &session.Manager{}, paths) ops := new(op.Ops) gtx := layout.Context{ Ops: ops, Constraints: layout.Exact(image.Pt(1080, 2000)), } defer func() { if r := recover(); r != nil { t.Fatalf("lifecycleControls() panicked with selected startup vault: %v", r) } }() _ = u.lifecycleControls(gtx) } func TestUIShouldPrioritizeLifecyclePrimaryActionsOnPhone(t *testing.T) { t.Parallel() phone := newUIWithSession("phone", &session.Manager{}) if !phone.shouldPrioritizeLifecyclePrimaryActions() { t.Fatal("phone.shouldPrioritizeLifecyclePrimaryActions() = false, want true") } desktop := newUIWithSession("desktop", &session.Manager{}) if desktop.shouldPrioritizeLifecyclePrimaryActions() { t.Fatal("desktop.shouldPrioritizeLifecyclePrimaryActions() = true, want false") } } func TestUIRecentVaultListWithSelectedRecentVaultDoesNotPanic(t *testing.T) { t.Parallel() dir := t.TempDir() paths := statePaths{ DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), SettingsPath: filepath.Join(dir, "settings.json"), UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), AutofillCachePath: filepath.Join(dir, "autofill-cache.json"), } first := newUIWithSession("phone", &session.Manager{}, paths) first.noteRecentVault("/sdcard/Download/sample-vault.kdbx") u := newUIWithSession("phone", &session.Manager{}, paths) ops := new(op.Ops) gtx := layout.Context{ Ops: ops, Constraints: layout.Exact(image.Pt(1080, 800)), } defer func() { if r := recover(); r != nil { t.Fatalf("recentVaultList() panicked with selected startup vault: %v", r) } }() _ = u.recentVaultList(gtx) } func TestUIPhoneGroupBarWithChildGroupsDoesNotPanic(t *testing.T) { t.Parallel() u := newUIWithModel("phone", vault.Model{ Groups: [][]string{ {"Crew"}, {"Crew", "Internet"}, {"Crew", "eMail"}, }, }) u.setCurrentPath([]string{"Crew"}) ops := new(op.Ops) gtx := layout.Context{ Ops: ops, Constraints: layout.Exact(image.Pt(1080, 700)), } defer func() { if r := recover(); r != nil { t.Fatalf("groupBar() panicked on phone with child groups: %v", r) } }() _ = u.groupBar(gtx) } func TestUIPhoneGroupBrowserStartsExpandedAtRootAndCollapsesInVisibleSubgroups(t *testing.T) { t.Parallel() u := newUIWithModel("phone", vault.Model{ Groups: [][]string{ {"Crew"}, {"Crew", "Internet"}, }, }) u.setCurrentPath(nil) if !u.phoneGroupBrowserExpanded { t.Fatal("phoneGroupBrowserExpanded = false at root, want true") } u.setCurrentPath([]string{"Crew", "Internet"}) if u.phoneGroupBrowserExpanded { t.Fatal("phoneGroupBrowserExpanded = true inside visible subgroup, want false") } } func TestUIPhoneGroupBrowserToggleDoesNotChangeCurrentGroupToolsState(t *testing.T) { t.Parallel() u := newUIWithModel("phone", vault.Model{ Groups: [][]string{ {"Crew"}, {"Crew", "Internet"}, }, }) u.groupControlsHidden = true u.setCurrentPath([]string{"Crew"}) u.phoneGroupBrowserExpanded = false u.phoneGroupBrowserExpanded = !u.phoneGroupBrowserExpanded if !u.groupControlsHidden { t.Fatal("groupControlsHidden = false, want phone group browser toggle to stay independent") } } func TestUIPhoneGroupBarDoesNotClampScrollableContentHeight(t *testing.T) { t.Parallel() u := newUIWithModel("phone", vault.Model{ Groups: [][]string{ {"Crew"}, {"Crew", "One"}, {"Crew", "Two"}, {"Crew", "Three"}, {"Crew", "Four"}, {"Crew", "Five"}, {"Crew", "Six"}, {"Crew", "Seven"}, {"Crew", "Eight"}, }, }) u.setCurrentPath([]string{"Crew"}) ops := new(op.Ops) gtx := layout.Context{ Ops: ops, Constraints: layout.Exact(image.Pt(1080, 2400)), } dims := u.groupBar(gtx) minOldCap := gtx.Dp(unit.Dp(220)) if dims.Size.Y <= minOldCap { t.Fatalf("groupBar() phone height = %d, want > %d to avoid nested-scroll clamp", dims.Size.Y, minOldCap) } } func TestUIPhoneStartsWithGroupToolsCollapsed(t *testing.T) { t.Parallel() u := newUIWithSession("phone", &session.Manager{}, statePaths{ DefaultSaveAsPath: filepath.Join(t.TempDir(), "default-save-path.kdbx"), RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), UIPreferencesPath: filepath.Join(t.TempDir(), "ui-preferences.json"), }) if !u.groupControlsHidden { t.Fatal("groupControlsHidden = false, want phone Group Tools collapsed by default") } } func TestUIPhoneListPanelWithExpandedGroupControlsAndEntriesDoesNotPanic(t *testing.T) { t.Parallel() u := newUIWithModel("phone", vault.Model{ Groups: [][]string{ {"Crew"}, {"Crew", "Internet"}, {"Crew", "eMail"}, }, Entries: []vault.Entry{ {ID: "amazon", Title: "Amazon", Username: "joe", Path: []string{"Crew", "Internet"}}, {ID: "mail", Title: "Mail", Username: "joe", Path: []string{"Crew", "eMail"}}, }, }) u.groupControlsHidden = false u.setCurrentPath([]string{"Crew"}) u.filter() ops := new(op.Ops) gtx := layout.Context{ Ops: ops, Constraints: layout.Exact(image.Pt(1080, 900)), } defer func() { if r := recover(); r != nil { t.Fatalf("listPanel() panicked on phone with groups, controls, and entries: %v", r) } }() _ = u.listPanel(gtx) } func TestUIVisibleEntrySnapshotIsStableAfterVisibleMutation(t *testing.T) { t.Parallel() u := newUIWithModel("phone", vault.Model{ Entries: []vault.Entry{ {ID: "1", Title: "Alpha", Path: []string{"Crew", "Internet"}}, {ID: "2", Title: "Beta", Path: []string{"Crew", "Internet"}}, }, }) u.state.NavigateToPath([]string{"Crew", "Internet"}) u.filter() visible, clicks := u.visibleEntrySnapshot() if len(visible) != 2 || len(clicks) != 2 { t.Fatalf("snapshot lengths = (%d, %d), want (2, 2)", len(visible), len(clicks)) } u.visible = u.visible[:1] u.entryClicks = u.entryClicks[:1] if got := visible[1].Title; got != "Beta" { t.Fatalf("visible snapshot second title = %q, want Beta", got) } if clicks[1] == nil { t.Fatal("snapshot click pointer = nil, want stable clickable pointer") } } func TestUIVisibleEntrySnapshotRegrowsClickableState(t *testing.T) { t.Parallel() u := newUIWithModel("phone", vault.Model{ Entries: []vault.Entry{ {ID: "1", Title: "Alpha", Path: []string{"Crew", "Internet"}}, {ID: "2", Title: "Beta", Path: []string{"Crew", "Internet"}}, }, }) u.state.NavigateToPath([]string{"Crew", "Internet"}) u.filter() u.entryClicks = u.entryClicks[:1] visible, clicks := u.visibleEntrySnapshot() if len(visible) != 2 || len(clicks) != 2 { t.Fatalf("snapshot lengths = (%d, %d), want (2, 2)", len(visible), len(clicks)) } if clicks[1] == nil { t.Fatal("regrown click pointer = nil, want usable clickable state") } } func TestUIPhoneBackReturnsFromSubscreenToEntries(t *testing.T) { t.Parallel() u := newUIWithModel("phone", vault.Model{ Entries: []vault.Entry{{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}}}, }) u.showAPITokensSection() if !u.handlePhoneBack() { t.Fatal("handlePhoneBack() = false, want true for phone subsection") } if u.state.Section != appstate.SectionEntries { t.Fatalf("state.Section = %q, want entries", u.state.Section) } } func TestUIPhoneBackClosesSettingsDialog(t *testing.T) { t.Parallel() u := newUIWithModel("phone", vault.Model{}) u.securityDialogOpen = true if !u.handlePhoneBack() { t.Fatal("handlePhoneBack() = false, want true for open settings dialog") } if u.securityDialogOpen { t.Fatal("securityDialogOpen = true after back, want false") } } func TestUISecurityDialogContentDoesNotPanicWithSmallViewport(t *testing.T) { t.Parallel() u := newUIWithModel("phone", vault.Model{}) u.securityDialogOpen = true ops := new(op.Ops) gtx := layout.Context{ Ops: ops, Constraints: layout.Exact(image.Pt(540, 420)), } defer func() { if r := recover(); r != nil { t.Fatalf("securityDialogContent() panicked in small viewport: %v", r) } }() _ = u.securityDialogContent(gtx) } func TestUIAPIAuditSectionShowsRecordedEvents(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.auditLog = apiaudit.New(10) u.auditLog.Record(apiaudit.Event{ Type: apiaudit.EventApprovalAllowed, TokenName: "Browser Extension", ClientName: "firefox", Message: "approved", }) u.showAPIAuditSection() events := u.apiAuditEvents() if len(events) != 1 { t.Fatalf("len(apiAuditEvents()) = %d, want 1", len(events)) } if events[0].TokenName != "Browser Extension" { t.Fatalf("apiAuditEvents()[0].TokenName = %q, want %q", events[0].TokenName, "Browser Extension") } } func TestUIAPIAuditEventsMatchFriendlyQuickFilters(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.auditLog = apiaudit.New(10) u.auditLog.Record(apiaudit.Event{ Type: apiaudit.EventApprovalAllowed, TokenName: "Browser Extension", Operation: apitokens.OperationCopyPassword, Message: "approved", }) u.auditLog.Record(apiaudit.Event{ Type: apiaudit.EventApprovalDenied, TokenName: "CLI", Operation: apitokens.OperationListEntries, Message: "denied", }) u.showAPIAuditSection() u.search.SetText("Allowed") if got := u.apiAuditEvents(); len(got) != 1 || got[0].Type != apiaudit.EventApprovalAllowed { t.Fatalf("apiAuditEvents() with Allowed = %#v, want allowed event", got) } u.search.SetText("copy password") if got := u.apiAuditEvents(); len(got) != 1 || got[0].Operation != apitokens.OperationCopyPassword { t.Fatalf("apiAuditEvents() with copy password = %#v, want copy_password event", got) } u.search.SetText("CLI") if got := u.apiAuditEvents(); len(got) != 1 || got[0].TokenName != "CLI" { t.Fatalf("apiAuditEvents() with CLI = %#v, want CLI token event", got) } } func TestUIAPIAuditMessagesGuideQuickFilters(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.showAPIAuditSection() if got := u.listEmptyState(); got.Title != "No API audit events yet" || got.Body != "Connect a trusted client, respond to approval prompts, or issue a token to start recording activity." { t.Fatalf("listEmptyState() = %#v, want updated API audit guidance", got) } if got := u.detailPlaceholderMessage(); got != "Select an audit event to inspect it, or use Search audit log or the quick filters above." { t.Fatalf("detailPlaceholderMessage() = %q, want quick-filter guidance", got) } u.search.SetText("allowed") if got := u.listEmptyState(); got.Title != "No matching audit events" || got.Body != `No audit events match "allowed". Clear the search or try a different quick filter.` { t.Fatalf("listEmptyState() with search = %#v, want quick-filter empty-state guidance", got) } } func TestUILifecycleSecuritySettingsSummaryMovesAdvancedFieldsOutOfOpenFlow(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) if got := u.lifecycleSecuritySettingsSummary(); got != "Cipher and KDF now live in Vault Settings so opening and creating a vault stays focused on the file, key material, and sync choices." { t.Fatalf("lifecycleSecuritySettingsSummary() = %q, want focused lifecycle guidance", got) } } func TestUISelectedEntryFollowsApplicationStateSelection(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{ {ID: "1", Title: "Bellagio", Path: []string{"Crew", "Internet"}}, {ID: "2", Title: "Vault Console", Path: []string{"Crew", "Internet"}}, }, }) u.state.NavigateToPath([]string{"Crew", "Internet"}) u.filter() u.state.SelectedEntryID = "2" got, ok := u.selectedEntry() if !ok { t.Fatal("selectedEntry() ok = false, want true") } if got.Title != "Vault Console" { t.Fatalf("selectedEntry().Title = %q, want %q", got.Title, "Vault Console") } } func TestUILockHidesVisibleEntries(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{ {ID: "1", Title: "Bellagio", Path: []string{"Crew", "Internet"}}, }, }) if err := u.state.Lock(); err != nil { t.Fatalf("state.Lock() error = %v", err) } u.filter() if got := u.filteredTitles(); len(got) != 0 { t.Fatalf("filteredTitles() = %v, want empty while locked", got) } } func TestUILifecycleActionsCreateSaveOpenLockAndUnlockLocalVault(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.masterPassword.SetText("correct horse battery staple") if err := u.createVaultAction(); err != nil { t.Fatalf("createVaultAction() error = %v", err) } if got := u.masterPassword.Text(); got != "" { t.Fatalf("masterPassword after create = %q, want empty", got) } if err := u.state.UpsertEntry(vault.Entry{ ID: "vault-console", Title: "Vault Console", Username: "dannyocean", Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }); err != nil { t.Fatalf("UpsertEntry() error = %v", err) } path := filepath.Join(t.TempDir(), "keepassgo.kdbx") u.saveAsPath.SetText(path) if err := u.saveAsAction(); err != nil { t.Fatalf("saveAsAction() error = %v", err) } if err := u.lockAction(); err != nil { t.Fatalf("lockAction() error = %v", err) } u.state.NavigateToPath([]string{"Root", "Internet"}) u.filter() if got := u.filteredTitles(); len(got) != 0 { t.Fatalf("filteredTitles() = %v, want empty while locked", got) } u.masterPassword.SetText("correct horse battery staple") if err := u.unlockAction(); err != nil { t.Fatalf("unlockAction() error = %v", err) } u.filter() if got := u.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) { t.Fatalf("filteredTitles() after unlock = %v, want [Vault Console]", got) } reopened := newUIWithSession("desktop", &session.Manager{}) reopened.masterPassword.SetText("correct horse battery staple") reopened.vaultPath.SetText(path) if err := reopened.openVaultAction(); err != nil { t.Fatalf("openVaultAction() error = %v", err) } if got := reopened.masterPassword.Text(); got != "" { t.Fatalf("masterPassword after open = %q, want empty", got) } reopened.state.NavigateToPath([]string{"Root", "Internet"}) reopened.filter() if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) { t.Fatalf("reopened filteredTitles() = %v, want [Vault Console]", got) } } func TestUICreateVaultUsesSelectedSecuritySettings(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.securityCipher.SetText(vault.CipherAES256) u.securityKDF.SetText(vault.KDFAES) u.masterPassword.SetText("correct horse battery staple") if err := u.createVaultAction(); err != nil { t.Fatalf("createVaultAction() error = %v", err) } path := filepath.Join(t.TempDir(), "secure.kdbx") u.saveAsPath.SetText(path) if err := u.saveAsAction(); err != nil { t.Fatalf("saveAsAction() error = %v", err) } var reopened session.Manager if err := reopened.Open(path, vault.MasterKey{Password: "correct horse battery staple"}); err != nil { t.Fatalf("Open() error = %v", err) } got := reopened.SecuritySettings() if got.Cipher != vault.CipherAES256 || got.KDF != vault.KDFAES { t.Fatalf("SecuritySettings() = %#v, want aes256/aes-kdf", got) } } func TestUISaveSecuritySettingsUpdatesExistingVault(t *testing.T) { t.Parallel() manager := &session.Manager{} u := newUIWithState("desktop", manager, statePaths{ DefaultSaveAsPath: filepath.Join(t.TempDir(), "default-save-path.kdbx"), RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), UIPreferencesPath: filepath.Join(t.TempDir(), "ui-preferences.json"), }) u.masterPassword.SetText("correct horse battery staple") if err := u.createVaultAction(); err != nil { t.Fatalf("createVaultAction() error = %v", err) } u.securityCipher.SetText(vault.CipherAES256) u.securityKDF.SetText(vault.KDFAES) if err := u.saveSecuritySettingsAction(); err != nil { t.Fatalf("saveSecuritySettingsAction() error = %v", err) } path := filepath.Join(t.TempDir(), "updated-secure.kdbx") u.saveAsPath.SetText(path) if err := u.saveAsAction(); err != nil { t.Fatalf("saveAsAction() error = %v", err) } var reopened session.Manager if err := reopened.Open(path, vault.MasterKey{Password: "correct horse battery staple"}); err != nil { t.Fatalf("Open() error = %v", err) } got := reopened.SecuritySettings() if got.Cipher != vault.CipherAES256 || got.KDF != vault.KDFAES { t.Fatalf("SecuritySettings() = %#v, want aes256/aes-kdf", got) } } func TestUISaveSettingsPersistsUIPreferences(t *testing.T) { t.Parallel() dir := t.TempDir() configPath := filepath.Join(dir, "ui-prefs.json") u := newUIWithSession("desktop", &session.Manager{}, statePaths{ DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), UIPreferencesPath: configPath, }) u.settingsGroupControls.Value = true u.settingsLifecycleAdvanced.Value = false u.settingsHistory.Value = false u.settingsDenseLayout.Value = true if err := u.saveSecuritySettingsAction(); err != nil { t.Fatalf("saveSecuritySettingsAction() error = %v", err) } reloaded := newUIWithSession("desktop", &session.Manager{}, statePaths{ DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), UIPreferencesPath: configPath, }) if !reloaded.groupControlsHidden { t.Fatal("groupControlsHidden after reload = false, want true") } if reloaded.lifecycleAdvancedHidden { t.Fatal("lifecycleAdvancedHidden after reload = true, want false") } if reloaded.historyHidden { t.Fatal("historyHidden after reload = true, want false") } if !reloaded.denseLayout { t.Fatal("denseLayout after reload = false, want true") } } func TestUILockAndUnlockClearMasterPasswordField(t *testing.T) { t.Parallel() u := newUIWithState("desktop", &session.Manager{}, statePaths{ DefaultSaveAsPath: filepath.Join(t.TempDir(), "default-save-path.kdbx"), RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), UIPreferencesPath: filepath.Join(t.TempDir(), "ui-preferences.json"), }) u.masterPassword.SetText("correct horse battery staple") if err := u.createVaultAction(); err != nil { t.Fatalf("createVaultAction() error = %v", err) } u.masterPassword.SetText("should-be-cleared") if err := u.lockAction(); err != nil { t.Fatalf("lockAction() error = %v", err) } if got := u.masterPassword.Text(); got != "" { t.Fatalf("masterPassword after lock = %q, want empty", got) } u.masterPassword.SetText("correct horse battery staple") if err := u.unlockAction(); err != nil { t.Fatalf("unlockAction() error = %v", err) } if got := u.masterPassword.Text(); got != "" { t.Fatalf("masterPassword after unlock = %q, want empty", got) } } func TestUIMasterKeyModesCreateOpenAndUnlockLocalVault(t *testing.T) { t.Parallel() tests := []struct { name string mode vault.MasterKeyMode password string keyFileData []byte }{ { name: "password only", mode: vault.MasterKeyModePasswordOnly, password: "correct horse battery staple", }, { name: "key file only", mode: vault.MasterKeyModeKeyFileOnly, keyFileData: []byte("key-file-only-material"), }, { name: "composite", mode: vault.MasterKeyModePasswordAndKeyFile, password: "correct horse battery staple", keyFileData: []byte("composite-key-material"), }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() keyFile := "" if len(tt.keyFileData) > 0 { keyFile = filepath.Join(t.TempDir(), "master.key") if err := os.WriteFile(keyFile, tt.keyFileData, 0o600); err != nil { t.Fatalf("WriteFile(master.key) error = %v", err) } } u := newUIWithSession("desktop", &session.Manager{}) u.setMasterKeyMode(tt.mode) u.masterPassword.SetText(tt.password) u.keyFilePath.SetText(keyFile) if err := u.createVaultAction(); err != nil { t.Fatalf("createVaultAction() error = %v", err) } if err := u.state.UpsertEntry(vault.Entry{ ID: "vault-console", Title: "Vault Console", Username: "dannyocean", Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }); err != nil { t.Fatalf("UpsertEntry() error = %v", err) } path := filepath.Join(t.TempDir(), "keepassgo.kdbx") u.saveAsPath.SetText(path) if err := u.saveAsAction(); err != nil { t.Fatalf("saveAsAction() error = %v", err) } if err := u.lockAction(); err != nil { t.Fatalf("lockAction() error = %v", err) } u.masterPassword.SetText(tt.password) u.keyFilePath.SetText(keyFile) if err := u.unlockAction(); err != nil { t.Fatalf("unlockAction() error = %v", err) } reopened := newUIWithSession("desktop", &session.Manager{}) reopened.setMasterKeyMode(tt.mode) reopened.masterPassword.SetText(tt.password) reopened.keyFilePath.SetText(keyFile) reopened.vaultPath.SetText(path) if err := reopened.openVaultAction(); err != nil { t.Fatalf("openVaultAction() error = %v", err) } reopened.state.NavigateToPath([]string{"Root", "Internet"}) reopened.filter() if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) { t.Fatalf("reopened filteredTitles() = %v, want [Vault Console]", got) } }) } } func TestUIChangeMasterKeyModeForExistingVault(t *testing.T) { t.Parallel() updated := filepath.Join(t.TempDir(), "updated.key") if err := os.WriteFile(updated, []byte("updated-key"), 0o600); err != nil { t.Fatalf("WriteFile(updated.key) error = %v", err) } stateDir := t.TempDir() u := newUIWithSession("desktop", &session.Manager{}, statePaths{ DefaultSaveAsPath: filepath.Join(stateDir, "default.kdbx"), RecentVaultsPath: filepath.Join(stateDir, "recent-vaults.json"), RecentRemotesPath: filepath.Join(stateDir, "recent-remotes.json"), UIPreferencesPath: filepath.Join(stateDir, "ui-prefs.json"), }) u.setMasterKeyMode(vault.MasterKeyModePasswordOnly) u.masterPassword.SetText("old-password") if err := u.createVaultAction(); err != nil { t.Fatalf("createVaultAction() error = %v", err) } if err := u.state.UpsertEntry(vault.Entry{ ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}, }); err != nil { t.Fatalf("UpsertEntry() error = %v", err) } path := filepath.Join(t.TempDir(), "keepassgo.kdbx") u.saveAsPath.SetText(path) if err := u.saveAsAction(); err != nil { t.Fatalf("saveAsAction() error = %v", err) } u.setMasterKeyMode(vault.MasterKeyModePasswordAndKeyFile) u.masterPassword.SetText("new-password") u.keyFilePath.SetText(updated) if err := u.changeMasterKeyAction(); err != nil { t.Fatalf("changeMasterKeyAction() error = %v", err) } if err := u.saveAction(); err != nil { t.Fatalf("saveAction() error = %v", err) } if err := u.lockAction(); err != nil { t.Fatalf("lockAction() error = %v", err) } u.masterPassword.SetText("old-password") u.keyFilePath.SetText("") u.setMasterKeyMode(vault.MasterKeyModePasswordOnly) u.runAction("unlock vault", u.unlockAction) if u.state.ErrorMessage == "" { t.Fatal("state.ErrorMessage = empty, want visible invalid master key error") } u.setMasterKeyMode(vault.MasterKeyModePasswordAndKeyFile) u.masterPassword.SetText("new-password") u.keyFilePath.SetText(updated) if err := u.unlockAction(); err != nil { t.Fatalf("unlockAction() with updated key error = %v", err) } reopened := newUIWithSession("desktop", &session.Manager{}) reopened.setMasterKeyMode(vault.MasterKeyModePasswordAndKeyFile) reopened.masterPassword.SetText("new-password") reopened.keyFilePath.SetText(updated) reopened.vaultPath.SetText(path) if err := reopened.openVaultAction(); err != nil { t.Fatalf("openVaultAction() with updated key error = %v", err) } reopened.state.NavigateToPath([]string{"Root", "Internet"}) reopened.filter() if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) { t.Fatalf("reopened filteredTitles() = %v, want [Vault Console]", got) } } func TestUIMasterKeyValidationErrorsAreVisible(t *testing.T) { t.Parallel() tests := []struct { name string password string keyFile string wantError string }{ { name: "requires either password or key file", wantError: "master password or key file is required", }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.masterPassword.SetText(tt.password) u.keyFilePath.SetText(tt.keyFile) u.runAction("create vault", u.createVaultAction) if got := u.state.ErrorMessage; got != tt.wantError { t.Fatalf("state.ErrorMessage = %q, want %q", got, tt.wantError) } if got := u.state.StatusMessage; got != "" { t.Fatalf("state.StatusMessage = %q, want empty on validation error", got) } }) } } func TestUIUnreadableAndInvalidMasterKeyErrorsAreVisible(t *testing.T) { t.Parallel() keyFile := filepath.Join(t.TempDir(), "master.key") if err := os.WriteFile(keyFile, []byte("key-material"), 0o600); err != nil { t.Fatalf("WriteFile(master.key) error = %v", err) } create := newUIWithSession("desktop", &session.Manager{}) create.setMasterKeyMode(vault.MasterKeyModeKeyFileOnly) create.keyFilePath.SetText(keyFile) if err := create.createVaultAction(); err != nil { t.Fatalf("createVaultAction() error = %v", err) } path := filepath.Join(t.TempDir(), "keepassgo.kdbx") create.saveAsPath.SetText(path) if err := create.saveAsAction(); err != nil { t.Fatalf("saveAsAction() error = %v", err) } unreadable := newUIWithSession("desktop", &session.Manager{}) unreadable.setMasterKeyMode(vault.MasterKeyModeKeyFileOnly) unreadable.keyFilePath.SetText(filepath.Join(t.TempDir(), "missing.key")) unreadable.runAction("open vault", unreadable.openVaultAction) if got := unreadable.state.ErrorMessage; got == "" || got[:14] != "read key file:" { t.Fatalf("state.ErrorMessage = %q, want read key file error", got) } wrong := newUIWithSession("desktop", &session.Manager{}) wrong.setMasterKeyMode(vault.MasterKeyModeKeyFileOnly) wrong.keyFilePath.SetText(filepath.Join(t.TempDir(), "wrong.key")) if err := os.WriteFile(wrong.keyFilePath.Text(), []byte("wrong-key"), 0o600); err != nil { t.Fatalf("WriteFile(wrong.key) error = %v", err) } wrong.vaultPath.SetText(path) wrong.runAction("open vault", wrong.openVaultAction) if got := wrong.state.ErrorMessage; got == "" || !bytes.Contains([]byte(got), []byte(vault.ErrInvalidMasterKey.Error())) { t.Fatalf("state.ErrorMessage = %q, want invalid master key error", got) } } func TestUIOpenRemoteAndSaveThroughConfiguredWebDAVTarget(t *testing.T) { t.Parallel() key := vault.MasterKey{Password: "correct horse battery staple"} model := vault.Model{ Entries: []vault.Entry{ { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", Password: "bellagio-pass-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{}, statePaths{ DefaultSaveAsPath: filepath.Join(t.TempDir(), "vault.kdbx"), RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), }) u.masterPassword.SetText("correct horse battery staple") u.remoteBaseURL.SetText(server.URL) u.remotePath.SetText("vaults/main.kdbx") u.selectedVaultRemoteProfileID = "" u.selectedVaultRemoteCredentialEntryID = "" 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: "bellagio-pass-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 TestUIStartOpenRemoteActionAppliesResultOnMainThread(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: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }}, } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Fatalf("unexpected method %s", r.Method) } 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()) })) defer server.Close() manager := &session.Manager{} u := newUIWithState("desktop", manager, statePaths{ DefaultSaveAsPath: filepath.Join(t.TempDir(), "default-save-path.kdbx"), RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), UIPreferencesPath: filepath.Join(t.TempDir(), "ui-preferences.json"), }) u.masterPassword.SetText(key.Password) u.remoteBaseURL.SetText(server.URL) u.remotePath.SetText("vaults/main.kdbx") u.startOpenRemoteAction() if got := u.loadingMessage; got != "Open remote vault..." { t.Fatalf("loadingMessage after start = %q, want %q", got, "Open remote vault...") } if manager.HasVault() { t.Fatal("manager.HasVault() = true before remote result applied, want false") } result := waitForBackgroundResult(t, u) u.applyBackgroundResult(result) if got := u.loadingMessage; got != "" { t.Fatalf("loadingMessage after apply = %q, want empty", got) } if got := u.state.ErrorMessage; got != "" { t.Fatalf("ErrorMessage after apply = %q, want empty", got) } if !manager.HasVault() { t.Fatal("manager.HasVault() = false after remote result applied, want true") } if got := u.filteredTitles(); len(got) != 0 { t.Fatalf("filteredTitles() = %v, want empty at root when entries only appear in child groups", got) } } func TestUIOpenRemoteReportsTransportFailure(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) url := server.URL server.Close() u := newUIWithState("desktop", &session.Manager{}, statePaths{ DefaultSaveAsPath: filepath.Join(t.TempDir(), "default-save-path.kdbx"), RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), UIPreferencesPath: filepath.Join(t.TempDir(), "ui-preferences.json"), }) u.masterPassword.SetText("correct horse battery staple") u.remoteBaseURL.SetText(url) u.remotePath.SetText("vaults/main.kdbx") u.runAction("open remote vault", u.openRemoteAction) if got := u.state.ErrorMessage; !strings.Contains(got, "open remote vault failed:") { t.Fatalf("state.ErrorMessage = %q, want open remote vault failure", got) } if got := u.state.StatusMessage; got != "" { t.Fatalf("state.StatusMessage = %q, want empty on remote open failure", got) } } func TestUIOpenRemoteActionUsesSelectedVaultBinding(t *testing.T) { t.Parallel() sess := &remoteOpenCaptureSession{ model: vault.Model{ Entries: []vault.Entry{{ ID: "remote-creds-1", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", Password: "bellagio-pass-1", Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ ID: "bellagio-webdav", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", }}, }, } u := newUIWithSession("desktop", sess) u.masterPassword.SetText("correct horse battery staple") u.selectedVaultRemoteProfileID = "bellagio-webdav" u.selectedVaultRemoteCredentialEntryID = "remote-creds-1" if err := u.openRemoteAction(); err != nil { t.Fatalf("openRemoteAction() error = %v", err) } if got := sess.remoteClient.BaseURL; got != "https://dav.example.invalid/remote.php/dav" { t.Fatalf("remoteClient.BaseURL = %q, want remote.php/dav URL", got) } if got := sess.remoteClient.Username; got != "linuscaldwell" { t.Fatalf("remoteClient.Username = %q, want linuscaldwell", got) } if got := sess.remoteClient.Password; got != "bellagio-pass-1" { t.Fatalf("remoteClient.Password = %q, want bellagio-pass-1", got) } if got := sess.remotePath; got != "files/bellagio/keepass.kdbx" { t.Fatalf("remotePath = %q, want files/bellagio/keepass.kdbx", got) } if got := u.remoteBaseURL.Text(); got != "https://dav.example.invalid/remote.php/dav" { t.Fatalf("remoteBaseURL = %q, want resolved profile base URL", got) } if got := u.remotePath.Text(); got != "files/bellagio/keepass.kdbx" { t.Fatalf("remotePath editor = %q, want resolved profile path", got) } } func TestUIOpenRemoteActionUsesImplicitSingleVaultBinding(t *testing.T) { t.Parallel() sess := &remoteOpenCaptureSession{ model: vault.Model{ Entries: []vault.Entry{{ ID: "remote-creds-1", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", Password: "bellagio-pass-1", Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ ID: "bellagio-webdav", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", }}, }, } u := newUIWithSession("desktop", sess) u.masterPassword.SetText("correct horse battery staple") u.vaultPath.SetText("/vaults/bellagio.kdbx") if err := u.openRemoteAction(); err != nil { t.Fatalf("openRemoteAction() error = %v", err) } if got := sess.remoteClient.BaseURL; got != "https://dav.example.invalid/remote.php/dav" { t.Fatalf("remoteClient.BaseURL = %q, want remote.php/dav URL", got) } if got := sess.remoteClient.Username; got != "linuscaldwell" { t.Fatalf("remoteClient.Username = %q, want linuscaldwell", got) } if got := sess.remoteClient.Password; got != "bellagio-pass-1" { t.Fatalf("remoteClient.Password = %q, want bellagio-pass-1", got) } if got := sess.remotePath; got != "files/bellagio/keepass.kdbx" { t.Fatalf("remotePath = %q, want files/bellagio/keepass.kdbx", got) } } func TestUIOpenRemoteActionBootstrapsFromLocalVaultBinding(t *testing.T) { t.Parallel() key := vault.MasterKey{Password: "correct horse battery staple"} localPath := filepath.Join(t.TempDir(), "bellagio.kdbx") remoteModel := vault.Model{ Entries: []vault.Entry{{ ID: "entry-1", Title: "Vault Console", Username: "dannyocean", Password: "remote-token", Path: []string{"Root", "Internet"}, }}, } var remoteBytes bytes.Buffer if err := vault.SaveKDBXWithKey(&remoteBytes, remoteModel, key); err != nil { t.Fatalf("SaveKDBXWithKey(remote) error = %v", err) } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { username, password, ok := r.BasicAuth() if !ok || username != "linuscaldwell" || password != "bellagio-pass-1" { t.Fatalf("BasicAuth() = (%q, %q, %t), want (linuscaldwell, bellagio-pass-1, true)", username, password, ok) } if r.Method != http.MethodGet { t.Fatalf("method = %s, want GET", r.Method) } w.Header().Set("ETag", "\"v1\"") _, _ = w.Write(remoteBytes.Bytes()) })) defer server.Close() localModel := vault.Model{} if _, err := appstate.ConfigureRemoteBinding(&localModel, appstate.RemoteBindingInput{ LocalVaultPath: localPath, RemoteProfileID: "bellagio-webdav", RemoteProfileName: "bellagio.kdbx · dav.example.invalid", BaseURL: server.URL, RemotePath: "files/bellagio/keepass.kdbx", CredentialEntryID: "remote-creds-1", CredentialTitle: "Bellagio WebDAV Sign-In · linuscaldwell", Username: "linuscaldwell", Password: "bellagio-pass-1", CredentialPath: []string{"Crew", "Internet"}, SyncMode: appstate.SyncModeAutomaticOnOpenSave, }); err != nil { t.Fatalf("ConfigureRemoteBinding(localModel) error = %v", err) } writeKDBXMainTestFile(t, localPath, localModel, key) u := newUIWithSession("desktop", &session.Manager{}) u.masterPassword.SetText(key.Password) u.applyRecentRemoteRecord(recentRemoteRecord{ BaseURL: server.URL, Path: "files/bellagio/keepass.kdbx", LocalVaultPath: localPath, RemoteProfileID: "bellagio-webdav", CredentialEntryID: "remote-creds-1", }) if err := u.openRemoteAction(); err != nil { t.Fatalf("openRemoteAction() error = %v", err) } if got := u.vaultPath.Text(); got != localPath { t.Fatalf("vaultPath = %q, want %q", got, localPath) } current, err := u.state.Session.Current() if err != nil { t.Fatalf("Session.Current() error = %v", err) } if got := current.EntriesInPath([]string{"Root", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" { t.Fatalf("EntriesInPath(Root/Internet) = %#v, want Vault Console", got) } } func TestUIStartOpenRemoteActionUsesSelectedVaultBinding(t *testing.T) { t.Parallel() localKey := vault.MasterKey{Password: "correct horse battery staple"} localPath := filepath.Join(t.TempDir(), "bellagio.kdbx") localModel := vault.Model{ Entries: []vault.Entry{{ ID: "remote-creds-1", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", Password: "bellagio-pass-1", Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ ID: "bellagio-webdav", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "", Path: "files/bellagio/keepass.kdbx", }}, } remoteModel := vault.Model{ Entries: []vault.Entry{{ ID: "vault-console", Title: "Vault Console", Username: "dannyocean", Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }}, } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Fatalf("unexpected method %s", r.Method) } var encoded bytes.Buffer if err := vault.SaveKDBXWithKey(&encoded, remoteModel, localKey); err != nil { t.Fatalf("SaveKDBXWithKey() error = %v", err) } w.Header().Set("ETag", "\"v1\"") _, _ = w.Write(encoded.Bytes()) })) defer server.Close() localModel.RemoteProfiles[0].BaseURL = server.URL manager := &session.Manager{} if err := manager.Create(localModel, localKey); err != nil { t.Fatalf("manager.Create() error = %v", err) } u := newUIWithSession("desktop", manager) u.masterPassword.SetText(localKey.Password) u.vaultPath.SetText(localPath) u.selectedVaultRemoteProfileID = "bellagio-webdav" u.selectedVaultRemoteCredentialEntryID = "remote-creds-1" u.startOpenRemoteAction() result := waitForBackgroundResult(t, u) u.applyBackgroundResult(result) if got := u.state.ErrorMessage; got != "" { t.Fatalf("ErrorMessage after apply = %q, want empty", got) } if got := u.remoteBaseURL.Text(); got != server.URL { t.Fatalf("remoteBaseURL = %q, want server URL from selected profile", got) } if got := u.remotePath.Text(); got != "files/bellagio/keepass.kdbx" { t.Fatalf("remotePath = %q, want selected profile path", got) } } func TestUIStartOpenRemoteActionUsesImplicitSingleVaultBinding(t *testing.T) { t.Parallel() localKey := vault.MasterKey{Password: "correct horse battery staple"} localPath := filepath.Join(t.TempDir(), "bellagio.kdbx") localModel := vault.Model{ Entries: []vault.Entry{{ ID: "remote-creds-1", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", Password: "bellagio-pass-1", Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ ID: "bellagio-webdav", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "", Path: "files/bellagio/keepass.kdbx", }}, } remoteModel := vault.Model{ Entries: []vault.Entry{{ ID: "vault-console", Title: "Vault Console", Username: "dannyocean", Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }}, } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Fatalf("unexpected method %s", r.Method) } var encoded bytes.Buffer if err := vault.SaveKDBXWithKey(&encoded, remoteModel, localKey); err != nil { t.Fatalf("SaveKDBXWithKey() error = %v", err) } w.Header().Set("ETag", "\"v1\"") _, _ = w.Write(encoded.Bytes()) })) defer server.Close() localModel.RemoteProfiles[0].BaseURL = server.URL manager := &session.Manager{} if err := manager.Create(localModel, localKey); err != nil { t.Fatalf("manager.Create() error = %v", err) } u := newUIWithSession("desktop", manager) u.masterPassword.SetText(localKey.Password) u.vaultPath.SetText(localPath) u.startOpenRemoteAction() result := waitForBackgroundResult(t, u) u.applyBackgroundResult(result) if got := u.state.ErrorMessage; got != "" { t.Fatalf("ErrorMessage after apply = %q, want empty", got) } if got := u.remoteBaseURL.Text(); got != server.URL { t.Fatalf("remoteBaseURL = %q, want server URL from implicit profile", got) } if got := u.remotePath.Text(); got != "files/bellagio/keepass.kdbx" { t.Fatalf("remotePath = %q, want implicit profile path", got) } } func TestUIStartOpenRemoteActionBootstrapsFromLocalVaultBinding(t *testing.T) { t.Parallel() key := vault.MasterKey{Password: "correct horse battery staple"} localPath := filepath.Join(t.TempDir(), "bellagio.kdbx") remoteModel := vault.Model{ Entries: []vault.Entry{{ ID: "entry-1", Title: "Vault Console", Username: "dannyocean", Password: "remote-token", Path: []string{"Root", "Internet"}, }}, } var remoteBytes bytes.Buffer if err := vault.SaveKDBXWithKey(&remoteBytes, remoteModel, key); err != nil { t.Fatalf("SaveKDBXWithKey(remote) error = %v", err) } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { username, password, ok := r.BasicAuth() if !ok || username != "linuscaldwell" || password != "bellagio-pass-1" { t.Fatalf("BasicAuth() = (%q, %q, %t), want (linuscaldwell, bellagio-pass-1, true)", username, password, ok) } if r.Method != http.MethodGet { t.Fatalf("method = %s, want GET", r.Method) } w.Header().Set("ETag", "\"v1\"") _, _ = w.Write(remoteBytes.Bytes()) })) defer server.Close() localModel := vault.Model{} if _, err := appstate.ConfigureRemoteBinding(&localModel, appstate.RemoteBindingInput{ LocalVaultPath: localPath, RemoteProfileID: "bellagio-webdav", RemoteProfileName: "bellagio.kdbx · dav.example.invalid", BaseURL: server.URL, RemotePath: "files/bellagio/keepass.kdbx", CredentialEntryID: "remote-creds-1", CredentialTitle: "Bellagio WebDAV Sign-In · linuscaldwell", Username: "linuscaldwell", Password: "bellagio-pass-1", CredentialPath: []string{"Crew", "Internet"}, SyncMode: appstate.SyncModeAutomaticOnOpenSave, }); err != nil { t.Fatalf("ConfigureRemoteBinding(localModel) error = %v", err) } writeKDBXMainTestFile(t, localPath, localModel, key) manager := &session.Manager{} u := newUIWithSession("desktop", manager) u.masterPassword.SetText(key.Password) u.applyRecentRemoteRecord(recentRemoteRecord{ BaseURL: server.URL, Path: "files/bellagio/keepass.kdbx", LocalVaultPath: localPath, RemoteProfileID: "bellagio-webdav", CredentialEntryID: "remote-creds-1", }) u.startOpenRemoteAction() if got := u.loadingMessage; got != "Open remote vault..." { t.Fatalf("loadingMessage after start = %q, want %q", got, "Open remote vault...") } result := waitForBackgroundResult(t, u) u.applyBackgroundResult(result) if got := u.state.ErrorMessage; got != "" { t.Fatalf("ErrorMessage after apply = %q, want empty", got) } if got := u.vaultPath.Text(); got != localPath { t.Fatalf("vaultPath = %q, want %q", got, localPath) } current, err := u.state.Session.Current() if err != nil { t.Fatalf("Session.Current() error = %v", err) } if got := current.EntriesInPath([]string{"Root", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" { t.Fatalf("EntriesInPath(Root/Internet) = %#v, want Vault Console", got) } } func TestUIOpenVaultActionSelectsSoleSavedRemoteBinding(t *testing.T) { t.Parallel() key := vault.MasterKey{Password: "correct horse battery staple"} path := filepath.Join(t.TempDir(), "bellagio.kdbx") model := vault.Model{ Entries: []vault.Entry{{ ID: "remote-creds-1", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", Password: "bellagio-pass-1", Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ ID: "bellagio-webdav", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", }}, } writeKDBXMainTestFile(t, path, model, key) u := newUIWithState("desktop", &session.Manager{}, statePaths{ DefaultSaveAsPath: filepath.Join(t.TempDir(), "default.kdbx"), RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), }) u.recentRemotes = []recentRemoteRecord{{ BaseURL: "https://dav.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", LocalVaultPath: path, RemoteProfileID: "bellagio-webdav", CredentialEntryID: "remote-creds-1", SyncMode: string(appstate.SyncModeManual), }} u.vaultPath.SetText(path) u.masterPassword.SetText(key.Password) u.selectedVaultRemoteProfileID = "stale-profile" u.selectedVaultRemoteCredentialEntryID = "stale-credential" u.remoteBaseURL.SetText("https://stale.example.invalid") u.remotePath.SetText("stale/path.kdbx") if err := u.openVaultAction(); err != nil { t.Fatalf("openVaultAction() error = %v", err) } if got := u.selectedVaultRemoteProfileID; got != "bellagio-webdav" { t.Fatalf("selectedVaultRemoteProfileID = %q, want bellagio-webdav", got) } if got := u.selectedVaultRemoteCredentialEntryID; got != "remote-creds-1" { t.Fatalf("selectedVaultRemoteCredentialEntryID = %q, want remote-creds-1", got) } if got := u.remoteBaseURL.Text(); got != "https://dav.example.invalid/remote.php/dav" { t.Fatalf("remoteBaseURL = %q, want resolved profile base URL", got) } if got := u.remotePath.Text(); got != "files/bellagio/keepass.kdbx" { t.Fatalf("remotePath = %q, want resolved profile path", got) } if got := u.selectedVaultRemoteSyncMode; got != appstate.SyncModeManual { t.Fatalf("selectedVaultRemoteSyncMode = %q, want manual from matching recent-remote state", got) } } func TestUIStartOpenVaultActionSelectsSoleSavedRemoteBinding(t *testing.T) { t.Parallel() key := vault.MasterKey{Password: "correct horse battery staple"} path := filepath.Join(t.TempDir(), "bellagio.kdbx") model := vault.Model{ Entries: []vault.Entry{{ ID: "remote-creds-1", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", Password: "bellagio-pass-1", Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ ID: "bellagio-webdav", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", }}, } writeKDBXMainTestFile(t, path, model, key) u := newUIWithState("desktop", &session.Manager{}, statePaths{ DefaultSaveAsPath: filepath.Join(t.TempDir(), "default.kdbx"), RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), }) u.vaultPath.SetText(path) u.masterPassword.SetText(key.Password) u.selectedVaultRemoteProfileID = "stale-profile" u.selectedVaultRemoteCredentialEntryID = "stale-credential" u.remoteBaseURL.SetText("https://stale.example.invalid") u.remotePath.SetText("stale/path.kdbx") u.startOpenVaultAction() result := waitForBackgroundResult(t, u) u.applyBackgroundResult(result) if got := u.state.ErrorMessage; got != "" { t.Fatalf("ErrorMessage after apply = %q, want empty", got) } if got := u.selectedVaultRemoteProfileID; got != "bellagio-webdav" { t.Fatalf("selectedVaultRemoteProfileID = %q, want bellagio-webdav", got) } if got := u.selectedVaultRemoteCredentialEntryID; got != "remote-creds-1" { t.Fatalf("selectedVaultRemoteCredentialEntryID = %q, want remote-creds-1", got) } if got := u.remoteBaseURL.Text(); got != "https://dav.example.invalid/remote.php/dav" { t.Fatalf("remoteBaseURL = %q, want resolved profile base URL", got) } if got := u.remotePath.Text(); got != "files/bellagio/keepass.kdbx" { t.Fatalf("remotePath = %q, want resolved profile path", got) } } func TestUIOpenVaultActionAutomaticallySynchronizesFromRemoteBinding(t *testing.T) { t.Parallel() key := vault.MasterKey{Password: "correct horse battery staple"} path := filepath.Join(t.TempDir(), "bellagio.kdbx") localModel := vault.Model{ Entries: []vault.Entry{{ ID: "remote-creds-1", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", Password: "bellagio-pass-1", Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ ID: "bellagio-webdav", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://stale.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", }}, } writeKDBXMainTestFile(t, path, localModel, key) var remoteBytes bytes.Buffer if err := vault.SaveKDBXWithKey(&remoteBytes, vault.Model{ Entries: []vault.Entry{{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}}}, }, key); err != nil { t.Fatalf("SaveKDBXWithKey(remote) error = %v", err) } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { username, password, ok := r.BasicAuth() if !ok || username != "linuscaldwell" || password != "bellagio-pass-1" { t.Fatalf("BasicAuth() = (%q, %q, %t), want (linuscaldwell, bellagio-pass-1, true)", username, password, ok) } if r.Method != http.MethodGet { t.Fatalf("method = %s, want GET", r.Method) } w.Header().Set("ETag", "\"v1\"") _, _ = w.Write(remoteBytes.Bytes()) })) defer server.Close() localModel.RemoteProfiles[0].BaseURL = server.URL writeKDBXMainTestFile(t, path, localModel, key) u := newUIWithState("desktop", &session.Manager{}, statePaths{ DefaultSaveAsPath: filepath.Join(t.TempDir(), "default.kdbx"), RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), }) u.recentRemotes = []recentRemoteRecord{{ BaseURL: server.URL, Path: "files/bellagio/keepass.kdbx", LocalVaultPath: path, RemoteProfileID: "bellagio-webdav", CredentialEntryID: "remote-creds-1", SyncMode: string(appstate.SyncModeAutomaticOnOpenSave), }} u.vaultPath.SetText(path) u.masterPassword.SetText(key.Password) if err := u.openVaultAction(); err != nil { t.Fatalf("openVaultAction() error = %v", err) } current, err := u.state.Session.Current() if err != nil { t.Fatalf("Session.Current() error = %v", err) } if _, err := current.EntryByID("vault-console"); err != nil { t.Fatalf("EntryByID(vault-console) error = %v, want remote entry merged on open", err) } if got := u.remoteBaseURL.Text(); got != server.URL { t.Fatalf("remoteBaseURL = %q, want %q", got, server.URL) } } func TestUIOpenVaultActionKeepsLocalVaultOpenWhenAutoSyncFails(t *testing.T) { t.Parallel() key := vault.MasterKey{Password: "correct horse battery staple"} path := filepath.Join(t.TempDir(), "bellagio.kdbx") localModel := vault.Model{ Entries: []vault.Entry{ {ID: "entry-1", Title: "Local Cache", Path: []string{"Root", "Internet"}}, {ID: "remote-creds-1", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", Password: "bellagio-pass-1", Path: []string{"Crew", "Internet"}}, }, RemoteProfiles: []vault.RemoteProfile{{ ID: "bellagio-webdav", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://unreachable.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", }}, } writeKDBXMainTestFile(t, path, localModel, key) u := newUIWithState("desktop", &session.Manager{}, statePaths{ DefaultSaveAsPath: filepath.Join(t.TempDir(), "default.kdbx"), RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), }) u.recentRemotes = []recentRemoteRecord{{ BaseURL: "https://unreachable.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", LocalVaultPath: path, RemoteProfileID: "bellagio-webdav", CredentialEntryID: "remote-creds-1", SyncMode: string(appstate.SyncModeAutomaticOnOpenSave), }} u.vaultPath.SetText(path) u.masterPassword.SetText(key.Password) if err := u.openVaultAction(); err != nil { t.Fatalf("openVaultAction() error = %v, want local open to succeed even if auto-sync fails", err) } current, err := u.state.Session.Current() if err != nil { t.Fatalf("Session.Current() error = %v", err) } if _, err := current.EntryByID("entry-1"); err != nil { t.Fatalf("EntryByID(entry-1) error = %v, want local vault opened", err) } if got := u.state.StatusMessage; !strings.Contains(got, "Remote sync on open failed:") { t.Fatalf("StatusMessage = %q, want nonfatal remote sync failure notice", got) } if got := u.state.ErrorMessage; got != "" { t.Fatalf("ErrorMessage = %q, want empty for nonfatal remote sync failure", got) } } func TestUISaveActionAutomaticallySynchronizesToRemoteBinding(t *testing.T) { t.Parallel() key := vault.MasterKey{Password: "correct horse battery staple"} path := filepath.Join(t.TempDir(), "bellagio.kdbx") localModel := vault.Model{ Entries: []vault.Entry{{ ID: "remote-creds-1", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", Password: "bellagio-pass-1", Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ ID: "bellagio-webdav", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://stale.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", }}, } writeKDBXMainTestFile(t, path, localModel, key) var ( savedRemote []byte putCount int ) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { username, password, ok := r.BasicAuth() if !ok || username != "linuscaldwell" || password != "bellagio-pass-1" { t.Fatalf("BasicAuth() = (%q, %q, %t), want (linuscaldwell, bellagio-pass-1, true)", username, password, ok) } switch r.Method { case http.MethodGet: w.Header().Set("ETag", "\"v1\"") var encoded bytes.Buffer if err := vault.SaveKDBXWithKey(&encoded, vault.Model{}, key); err != nil { t.Fatalf("SaveKDBXWithKey(remote) error = %v", err) } _, _ = w.Write(encoded.Bytes()) case http.MethodPut: putCount++ var err error savedRemote, err = io.ReadAll(r.Body) if err != nil { t.Fatalf("ReadAll(PUT body) error = %v", err) } w.Header().Set("ETag", "\"v2\"") w.WriteHeader(http.StatusCreated) default: t.Fatalf("unexpected method %s", r.Method) } })) defer server.Close() localModel.RemoteProfiles[0].BaseURL = server.URL writeKDBXMainTestFile(t, path, localModel, key) u := newUIWithState("desktop", &session.Manager{}, statePaths{ DefaultSaveAsPath: filepath.Join(t.TempDir(), "default.kdbx"), RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), }) u.recentRemotes = []recentRemoteRecord{{ BaseURL: server.URL, Path: "files/bellagio/keepass.kdbx", LocalVaultPath: path, RemoteProfileID: "bellagio-webdav", CredentialEntryID: "remote-creds-1", SyncMode: string(appstate.SyncModeAutomaticOnOpenSave), }} u.vaultPath.SetText(path) u.masterPassword.SetText(key.Password) if err := u.openVaultAction(); err != nil { t.Fatalf("openVaultAction() error = %v", err) } if err := u.state.UpsertEntry(vault.Entry{ID: "entry-1", Title: "Vault Console", 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 == 0 { t.Fatal("remote PUT count = 0, want automatic remote synchronize on save") } loaded, err := vault.LoadKDBXWithKey(bytes.NewReader(savedRemote), key) if err != nil { t.Fatalf("LoadKDBXWithKey(savedRemote) error = %v", err) } if _, err := loaded.EntryByID("entry-1"); err != nil { t.Fatalf("EntryByID(entry-1) error = %v, want saved entry on remote", err) } } func TestPickExistingFileOutputExtractsPathFromPortalNoise(t *testing.T) { t.Parallel() output := strings.Join([]string{ "(zenity:1): Gdk-DEBUG: Ignoring portal setting", "/home/tester/vaults/bellagio.kdbx", "", }, "\n") got, err := parsePickedFilePath([]byte(output)) if err != nil { t.Fatalf("parsePickedFilePath() error = %v", err) } if got != "/home/tester/vaults/bellagio.kdbx" { t.Fatalf("parsePickedFilePath() = %q, want /home/tester/vaults/bellagio.kdbx", 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: "bellagio-pass-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{}, statePaths{ DefaultSaveAsPath: filepath.Join(t.TempDir(), "vault.kdbx"), RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), }) u.masterPassword.SetText("correct horse battery staple") u.remoteBaseURL.SetText(server.URL) u.remotePath.SetText("vaults/main.kdbx") u.selectedVaultRemoteProfileID = "" u.selectedVaultRemoteCredentialEntryID = "" 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: "bellagio-pass-2", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }); err != nil { t.Fatalf("UpsertEntry() error = %v", err) } u.runAction("save vault", u.saveAction) if got := u.state.ErrorMessage; got != "Save conflict: the remote vault changed. Reopen it and retry the save." { t.Fatalf("state.ErrorMessage = %q, want normalized save conflict guidance", got) } if got := u.state.StatusMessage; got != "" { t.Fatalf("state.StatusMessage = %q, want empty after remote save conflict", got) } if !u.state.Dirty { t.Fatal("Dirty = false, want true after remote save conflict") } if putCount != 1 { t.Fatalf("remote PUT count = %d, want 1", putCount) } } func TestUIAdvancedSynchronizeFromLocalMergesIntoCurrentVault(t *testing.T) { t.Parallel() key := vault.MasterKey{Password: "correct horse battery staple"} currentPath := filepath.Join(t.TempDir(), "current.kdbx") otherPath := filepath.Join(t.TempDir(), "other.kdbx") writeKDBXMainTestFile(t, currentPath, vault.Model{ Entries: []vault.Entry{{ ID: "entry-current", Title: "Vault Console", Username: "dannyocean", Password: "token-current", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }}, }, key) writeKDBXMainTestFile(t, otherPath, vault.Model{ Entries: []vault.Entry{{ ID: "entry-other", Title: "Bellagio", Username: "rustyryan", Password: "token-other", URL: "https://bellagio.example.invalid", Path: []string{"Root", "Internet"}, }}, }, key) u := newUIWithSession("desktop", &session.Manager{}) u.masterPassword.SetText(key.Password) u.vaultPath.SetText(currentPath) if err := u.openVaultAction(); err != nil { t.Fatalf("openVaultAction() error = %v", err) } u.openAdvancedSyncDialog() u.syncDirection = syncDirectionPull u.syncSourceMode = syncSourceLocal u.syncLocalPath.SetText(otherPath) if err := u.advancedSyncAction(); err != nil { t.Fatalf("advancedSyncAction() error = %v", err) } var reopened session.Manager if err := reopened.Open(currentPath, key); err != nil { t.Fatalf("reopen Open(current) error = %v", err) } model, err := reopened.Current() if err != nil { t.Fatalf("reopened Current() error = %v", err) } if got := len(model.EntriesInPath([]string{"Root", "Internet"})); got != 2 { t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", got) } } func TestUIAdvancedSynchronizeFromImportedLocalVaultMergesIntoCurrentVault(t *testing.T) { t.Parallel() key := vault.MasterKey{Password: "correct horse battery staple"} currentPath := filepath.Join(t.TempDir(), "current.kdbx") writeKDBXMainTestFile(t, currentPath, vault.Model{ Entries: []vault.Entry{{ ID: "entry-current", Title: "Vault Console", Username: "dannyocean", Password: "token-current", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }}, }, key) var other bytes.Buffer if err := vault.SaveKDBX(&other, vault.Model{ Entries: []vault.Entry{{ ID: "entry-other", Title: "Bellagio", Username: "rustyryan", Password: "token-other", URL: "https://bellagio.example.invalid", Path: []string{"Root", "Internet"}, }}, }, key.Password); err != nil { t.Fatalf("SaveKDBX(other) error = %v", err) } u := newUIWithSession("desktop", &session.Manager{}) u.masterPassword.SetText(key.Password) u.vaultPath.SetText(currentPath) if err := u.openVaultAction(); err != nil { t.Fatalf("openVaultAction() error = %v", err) } u.openAdvancedSyncDialog() u.syncDirection = syncDirectionPull u.syncSourceMode = syncSourceLocal u.syncLocalImportName = "Selected Android vault" u.syncLocalImportContent = other.Bytes() u.syncLocalPath.SetText("Selected Android vault") if err := u.advancedSyncAction(); err != nil { t.Fatalf("advancedSyncAction() error = %v", err) } var reopened session.Manager if err := reopened.Open(currentPath, key); err != nil { t.Fatalf("reopen Open(current) error = %v", err) } model, err := reopened.Current() if err != nil { t.Fatalf("reopened Current() error = %v", err) } if got := len(model.EntriesInPath([]string{"Root", "Internet"})); got != 2 { t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", got) } } func TestUIStartOpenVaultActionAppliesResultOnMainThread(t *testing.T) { t.Parallel() key := vault.MasterKey{Password: "correct horse battery staple"} path := filepath.Join(t.TempDir(), "vault.kdbx") writeKDBXMainTestFile(t, path, vault.Model{ Entries: []vault.Entry{{ ID: "entry-1", Title: "Vault Console", Username: "dannyocean", Password: "token-current", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }}, }, key) manager := &session.Manager{} u := newUIWithSession("desktop", manager) u.masterPassword.SetText(key.Password) u.vaultPath.SetText(path) u.startOpenVaultAction() if got := u.loadingMessage; got != "Open vault..." { t.Fatalf("loadingMessage after start = %q, want %q", got, "Open vault...") } if manager.HasVault() { t.Fatal("manager.HasVault() = true before background result applied, want false") } result := waitForBackgroundResult(t, u) u.applyBackgroundResult(result) if got := u.loadingMessage; got != "" { t.Fatalf("loadingMessage after apply = %q, want empty", got) } if got := u.state.ErrorMessage; got != "" { t.Fatalf("ErrorMessage after apply = %q, want empty", got) } if !manager.HasVault() { t.Fatal("manager.HasVault() = false after background result applied, want true") } if got := u.filteredTitles(); len(got) != 0 { t.Fatalf("filteredTitles() = %v, want empty at root when entries only appear in child groups", got) } } func TestUIStartUnlockActionAppliesResultOnMainThread(t *testing.T) { t.Parallel() key := vault.MasterKey{Password: "correct horse battery staple"} manager := &session.Manager{} u := newUIWithSession("desktop", manager) u.masterPassword.SetText(key.Password) 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(key.Password) u.startUnlockAction() if got := u.loadingMessage; got != "Unlock vault..." { t.Fatalf("loadingMessage after start = %q, want %q", got, "Unlock vault...") } if !manager.IsLocked() { t.Fatal("manager.IsLocked() = false before background result applied, want true") } result := waitForBackgroundResult(t, u) u.applyBackgroundResult(result) if got := u.loadingMessage; got != "" { t.Fatalf("loadingMessage after apply = %q, want empty", got) } if manager.IsLocked() { t.Fatal("manager.IsLocked() = true after background result applied, want false") } } func TestUIAdvancedSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) { t.Parallel() key := vault.MasterKey{Password: "correct horse battery staple"} currentPath := filepath.Join(t.TempDir(), "current.kdbx") writeKDBXMainTestFile(t, currentPath, vault.Model{ Entries: []vault.Entry{{ ID: "entry-current", Title: "Vault Console", Username: "dannyocean", Password: "token-current", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }}, }, key) var remoteBytes bytes.Buffer if err := vault.SaveKDBXWithKey(&remoteBytes, vault.Model{ Entries: []vault.Entry{{ ID: "entry-remote", Title: "Bellagio", Username: "rustyryan", Password: "token-remote", URL: "https://bellagio.example.invalid", Path: []string{"Root", "Internet"}, }}, }, key); err != nil { t.Fatalf("SaveKDBXWithKey(remote) error = %v", err) } etag := "\"v1\"" server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: w.Header().Set("ETag", etag) _, _ = w.Write(remoteBytes.Bytes()) case http.MethodPut: payload, err := io.ReadAll(r.Body) if err != nil { t.Fatalf("ReadAll(PUT body) error = %v", err) } remoteBytes.Reset() if _, err := remoteBytes.Write(payload); err != nil { t.Fatalf("Write(remoteBytes) error = %v", err) } etag = "\"v2\"" w.Header().Set("ETag", etag) w.WriteHeader(http.StatusNoContent) default: t.Fatalf("unexpected method %s", r.Method) } })) defer server.Close() u := newUIWithSession("desktop", &session.Manager{}) u.masterPassword.SetText(key.Password) u.vaultPath.SetText(currentPath) if err := u.openVaultAction(); err != nil { t.Fatalf("openVaultAction() error = %v", err) } u.openAdvancedSyncDialog() u.syncDirection = syncDirectionPush u.syncSourceMode = syncSourceRemote u.syncRemoteBaseURL.SetText(server.URL) u.syncRemotePath.SetText("vaults/other.kdbx") if err := u.advancedSyncAction(); err != nil { t.Fatalf("advancedSyncAction() error = %v", err) } var reopened session.Manager if err := reopened.OpenRemote(webdav.Client{BaseURL: server.URL}, "vaults/other.kdbx", key); err != nil { t.Fatalf("OpenRemote(reopened) error = %v", err) } model, err := reopened.Current() if err != nil { t.Fatalf("reopened Current() error = %v", err) } if got := len(model.EntriesInPath([]string{"Root", "Internet"})); got != 2 { t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", got) } } 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: "Security Office", Path: []string{"Root", "Security Office"}}, }, }) 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", "Internet", "Security Office"}) { t.Fatalf("childGroups() after create = %v, want [Finance Internet Security Office]", 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", "Internet", "Security Office"}) { t.Fatalf("childGroups() after rename = %v, want [Budget Internet Security Office]", got) } u.state.NavigateToPath([]string{"Root", "Budget"}) u.filter() u.armDeleteCurrentGroupAction() if err := u.deleteCurrentGroupAction(); err != nil { t.Fatalf("deleteCurrentGroupAction() error = %v", err) } if !slices.Equal(u.state.CurrentPath, []string{"Root"}) { t.Fatalf("state.CurrentPath after delete = %v, want [Root]", u.state.CurrentPath) } if got := u.childGroups(); !slices.Equal(got, []string{"Internet", "Security Office"}) { t.Fatalf("childGroups() after delete = %v, want [Internet Security Office]", got) } } func TestUIGroupControlsCanBeCollapsed(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{}) u.showEntriesSection() if u.groupControlsHidden { t.Fatal("groupControlsHidden = true, want false by default") } u.groupControlsHidden = true if !u.groupControlsHidden { t.Fatal("groupControlsHidden = false, want true after collapsing") } u.groupControlsHidden = false if u.groupControlsHidden { t.Fatal("groupControlsHidden = true, want false after expanding") } } func TestUIGroupNavigationLabelsDistinguishRootCurrentAndParent(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Groups: [][]string{{"Root"}, {"Root", "Infrastructure"}, {"Root", "Infrastructure", "Prod"}}, }) u.showEntriesSection() if got := u.currentGroupDisplayName(); got != "Vault root (/)" { t.Fatalf("currentGroupDisplayName() at root = %q, want %q", got, "Vault root (/)") } if got := u.parentGroupDisplayName(); got != "Vault root (/)" { t.Fatalf("parentGroupDisplayName() at root = %q, want %q", got, "Vault root (/)") } if got := u.createGroupLabel(); got != "Create Top-Level Group" { t.Fatalf("createGroupLabel() at root = %q, want %q", got, "Create Top-Level Group") } u.setCurrentPath([]string{"Root", "Infrastructure", "Prod"}) if got := u.currentGroupDisplayName(); got != "Infrastructure / Prod" { t.Fatalf("currentGroupDisplayName() = %q, want %q", got, "Infrastructure / Prod") } if got := u.parentGroupDisplayName(); got != "Infrastructure" { t.Fatalf("parentGroupDisplayName() = %q, want %q", got, "Infrastructure") } if got := u.createGroupLabel(); got != "Create Subgroup" { t.Fatalf("createGroupLabel() = %q, want %q", got, "Create Subgroup") } } func TestUIParentGroupDoesNotShowDescendantEntries(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{ {ID: "joe-note", Title: "Crew Note", Path: []string{"Crew"}}, {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}}, {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}}, }, }) u.showEntriesSection() u.state.NavigateToPath([]string{"Crew"}) u.filter() if got := u.filteredTitles(); !slices.Equal(got, []string{"Crew Note"}) { t.Fatalf("filteredTitles() = %v, want only direct entries under Crew", got) } } func TestUICreateGroupActionSupportsNestedSubgroups(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{}) u.showEntriesSection() u.state.NavigateToPath([]string{"Root"}) u.groupName.SetText("Infrastructure / Prod") if err := u.createGroupAction(); err != nil { t.Fatalf("createGroupAction() error = %v", err) } if got := u.state.Session.(*uiSession).model.ChildGroups([]string{"Root"}); !slices.Equal(got, []string{"Infrastructure"}) { t.Fatalf("ChildGroups(Root) = %v, want [Infrastructure]", got) } if got := u.state.Session.(*uiSession).model.ChildGroups([]string{"Root", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) { t.Fatalf("ChildGroups(Root/Infrastructure) = %v, want [Prod]", got) } } func TestUIMoveCurrentGroupActionMovesHierarchy(t *testing.T) { t.Parallel() model := vault.Model{ Entries: []vault.Entry{ {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}}, }, } model.CreateGroup([]string{"Root", "Internet"}, "Infrastructure") u := newUIWithModel("desktop", model) u.showEntriesSection() u.setCurrentPath([]string{"Root", "Internet"}) u.groupParentPath.SetText("Root / Crew") if err := u.moveCurrentGroupAction(); err != nil { t.Fatalf("moveCurrentGroupAction() error = %v", err) } if !slices.Equal(u.state.CurrentPath, []string{"Root", "Crew", "Internet"}) { t.Fatalf("state.CurrentPath = %v, want [Root Crew Internet]", u.state.CurrentPath) } got := u.state.Session.(*uiSession).model.EntriesInPath([]string{"Root", "Crew", "Internet"}) if len(got) != 1 || got[0].ID != "vault-console" { t.Fatalf("EntriesInPath(Root/Crew/Internet) = %#v, want moved vault-console entry", 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: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }, { ID: "ha", Title: "Security Office", Username: "rustyryan", Password: "bellagio-pass-2", URL: "https://ha.example.test", Path: []string{"Root", "Security Office"}, }, }, }) u.showEntriesSection() u.state.NavigateToPath([]string{"Root", "Internet"}) u.filter() u.state.SelectedEntryID = "vault-console" u.loadSelectedEntryIntoEditor() u.entryPath.SetText("Root / Security Office") 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", "Security Office"}) u.filter() if got := u.filteredTitles(); !slices.Equal(got, []string{"Security Office", "Vault Console"}) { t.Fatalf("filteredTitles() in destination group = %v, want [Vault Console Security Office]", 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: "bellagio-pass-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("bellagio-pass-2") if err := u.saveEntryAction(); err != nil { t.Fatalf("saveEntryAction() error = %v", err) } u.filter() if entry, ok := u.selectedEntry(); !ok || entry.Password != "bellagio-pass-2" { t.Fatalf("selectedEntry() = %#v, want updated password bellagio-pass-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("bellagio-pass-1") u.entryURL.SetText("https://bellagio.example.invalid") u.entryNotes.SetText("Registrar account") u.entryTags.SetText("dns, registrar") u.entryPath.SetText("Root / Internet") u.setCustomFieldRows(map[string]string{ "Environment": "prod", "Account ID": "12345", }) if err := u.saveEntryAction(); err != nil { t.Fatalf("saveEntryAction() create error = %v", err) } u.filter() if got := u.filteredTitles(); !slices.Equal(got, []string{"Bellagio"}) { t.Fatalf("filteredTitles() = %v, want [Bellagio]", got) } item, ok := u.selectedEntry() if !ok { t.Fatal("selectedEntry() ok = false, want created entry") } if item.Title != "Bellagio" || item.Username != "rustyryan" || item.Password != "bellagio-pass-1" || item.URL != "https://bellagio.example.invalid" { t.Fatalf("selectedEntry() = %#v, want created Bellagio credentials", item) } if item.Notes != "Registrar account" { t.Fatalf("selectedEntry().Notes = %q, want %q", item.Notes, "Registrar account") } if !slices.Equal(item.Tags, []string{"dns", "registrar"}) { t.Fatalf("selectedEntry().Tags = %v, want [dns registrar]", item.Tags) } if item.Fields["Environment"] != "prod" || item.Fields["Account ID"] != "12345" { t.Fatalf("selectedEntry().Fields = %#v, want parsed custom fields", item.Fields) } } func TestUILoadSelectedEntryIntoEditorPopulatesStructuredCustomFields(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{ { ID: "gitlab", Title: "Gitlab", Path: []string{"Root", "Internet"}, Fields: map[string]string{ "AndroidApp1": "androidapp://com.gitlab.android", "OTP": "123456", }, }, }, }) u.showEntriesSection() u.state.NavigateToPath([]string{"Root", "Internet"}) u.filter() u.state.SelectedEntryID = "gitlab" u.loadSelectedEntryIntoEditor() if len(u.customFieldKeys) != 2 || len(u.customFieldValues) != 2 { t.Fatalf("custom field rows = %d/%d, want 2 rows", len(u.customFieldKeys), len(u.customFieldValues)) } got := map[string]string{} for i := range u.customFieldKeys { got[u.customFieldKeys[i].Text()] = u.customFieldValues[i].Text() } if got["AndroidApp1"] != "androidapp://com.gitlab.android" || got["OTP"] != "123456" { t.Fatalf("custom field rows = %#v, want AndroidApp1 and OTP values", got) } } func TestUIEditingEntryPathMovesEntryBetweenGroups(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{ { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", Password: "bellagio-pass-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("bellagio-pass-1") u.entryURL.SetText("https://bellagio.example.invalid") u.entryPath.SetText("Root / Internet") if err := u.instantiateSelectedTemplateAction(); err != nil { t.Fatalf("instantiateSelectedTemplateAction() error = %v", err) } u.showEntriesSection() u.state.NavigateToPath([]string{"Root", "Internet"}) u.filter() u.state.SelectedEntryID = "entry-1" u.loadSelectedEntryIntoEditor() attachmentPath := filepath.Join(t.TempDir(), "token.txt") attachmentExportPath := filepath.Join(t.TempDir(), "exported.txt") content := []byte("attachment-content") if err := os.WriteFile(attachmentPath, content, 0o600); err != nil { t.Fatalf("WriteFile(attachmentPath) error = %v", err) } u.attachmentPath.SetText(attachmentPath) u.attachmentName.SetText("token.txt") if err := u.addAttachmentAction(); err != nil { t.Fatalf("addAttachmentAction() error = %v", err) } if got := u.selectedAttachmentNames(); !slices.Equal(got, []string{"token.txt"}) { t.Fatalf("selectedAttachmentNames() = %v, want [token.txt]", got) } replacementPath := filepath.Join(t.TempDir(), "token-replacement.txt") replacement := []byte("attachment-replacement") if err := os.WriteFile(replacementPath, replacement, 0o600); err != nil { t.Fatalf("WriteFile(replacementPath) error = %v", err) } u.attachmentPath.SetText(replacementPath) if err := u.replaceAttachmentAction(); err != nil { t.Fatalf("replaceAttachmentAction() error = %v", err) } u.exportAttachmentPath.SetText(attachmentExportPath) if err := u.exportAttachmentAction(); err != nil { t.Fatalf("exportAttachmentAction() error = %v", err) } exported, err := os.ReadFile(attachmentExportPath) if err != nil { t.Fatalf("ReadFile(exportAttachmentPath) error = %v", err) } if !bytes.Equal(exported, replacement) { t.Fatalf("exported attachment = %q, want %q", exported, replacement) } if err := u.removeAttachmentAction(); err != nil { t.Fatalf("removeAttachmentAction() error = %v", err) } u.showTemplatesSection() u.filter() u.state.SelectedEntryID = "tpl-1" if err := u.deleteSelectedTemplateAction(); err != nil { t.Fatalf("deleteSelectedTemplateAction() error = %v", err) } u.filter() if got := u.filteredTitles(); len(got) != 0 { t.Fatalf("template filteredTitles() after delete = %v, want empty", got) } } func TestUITemplatesCanBeBrowsedCreatedEditedDeletedAndInstantiated(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Templates: []vault.Entry{ { ID: "tpl-existing", Title: "SSH Login", Username: "root", Password: "template-password", Path: []string{"Templates", "Infra"}, }, }, }) u.showTemplatesSection() if got := u.childGroups(); !slices.Equal(got, []string{"Infra"}) { t.Fatalf("childGroups() = %v, want [Infra] at template root", got) } u.state.NavigateToPath([]string{"Templates", "Infra"}) u.filter() if got := u.filteredTitles(); !slices.Equal(got, []string{"SSH Login"}) { t.Fatalf("filteredTitles() = %v, want [SSH Login] in template path", got) } u.state.SelectedEntryID = "" u.loadSelectedEntryIntoEditor() u.entryID.SetText("tpl-web") u.entryTitle.SetText("Website Login") u.entryUsername.SetText("template-user") u.entryPassword.SetText("template-password") u.entryNotes.SetText("Reusable template for website accounts.") u.entryTags.SetText("template, web") u.entryPath.SetText("Templates / Web") if err := u.saveTemplateAction(); err != nil { t.Fatalf("saveTemplateAction(create) error = %v", err) } u.state.NavigateToPath([]string{"Templates", "Web"}) u.filter() if got := u.filteredTitles(); !slices.Equal(got, []string{"Website Login"}) { t.Fatalf("filteredTitles() after create = %v, want [Website Login]", got) } u.state.SelectedEntryID = "tpl-web" u.loadSelectedEntryIntoEditor() u.entryTitle.SetText("Website Login Updated") u.setCustomFieldRows(map[string]string{"Environment": "prod"}) if err := u.saveTemplateAction(); err != nil { t.Fatalf("saveTemplateAction(edit) error = %v", err) } u.filter() selected, ok := u.selectedEntry() if !ok { t.Fatal("selectedEntry() ok = false, want updated template") } if selected.Title != "Website Login Updated" { t.Fatalf("selectedEntry().Title = %q, want %q", selected.Title, "Website Login Updated") } if selected.Fields["Environment"] != "prod" { t.Fatalf("selectedEntry().Fields[Environment] = %q, want %q", selected.Fields["Environment"], "prod") } u.entryID.SetText("entry-1") u.entryTitle.SetText("Bellagio") u.entryUsername.SetText("rustyryan") u.entryPassword.SetText("bellagio-pass-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 TestUIAttachmentActionSummaryReflectsSelectionState(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() if got := u.attachmentActionSummary(); !strings.Contains(got, "Select an attachment above") { t.Fatalf("attachmentActionSummary() = %q, want prompt to select an attachment", got) } u.attachmentName.SetText("token.txt") if got := u.attachmentActionSummary(); !strings.Contains(got, "Selected attachment") { t.Fatalf("attachmentActionSummary() = %q, want selected attachment guidance", got) } u.attachmentName.SetText("missing.txt") if got := u.attachmentActionSummary(); !strings.Contains(got, "is not on this entry yet") { t.Fatalf("attachmentActionSummary() = %q, want missing attachment guidance", 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: "bellagio-pass-2", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, History: []vault.Entry{ { ID: "vault-console-h1", Title: "Vault Console", Username: "dannyocean", Password: "bellagio-pass-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 != "bellagio-pass-1" { t.Fatalf("selectedEntry() = %#v, want restored password bellagio-pass-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: "bellagio-pass-2", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, History: []vault.Entry{ { ID: "vault-console-h1", Title: "Vault Console", Username: "dannyocean", Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, Notes: "previous token", }, { ID: "vault-console-h0", Title: "Vault Console", Username: "dannyocean", Password: "bellagio-pass-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 != "bellagio-pass-0" { t.Fatalf("selectedHistoryEntry().Password = %q, want %q", selected.Password, "bellagio-pass-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: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }, }, }) u.clipboardWriter = &memoryClipboardWriter{} 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: "bellagio-pass-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}, defaultAccessibilityPreferences(), true) hi := fieldFocusAppearance(unit.Metric{PxPerDp: 2.5, PxPerSp: 2.5}, defaultAccessibilityPreferences(), true) unfocused := fieldFocusAppearance(unit.Metric{PxPerDp: 1, PxPerSp: 1}, defaultAccessibilityPreferences(), false) if got := lo.MinHeight; got != 44 { t.Fatalf("fieldFocusAppearance(low).MinHeight = %d, want 44", got) } if got := hi.MinHeight; got != 110 { t.Fatalf("fieldFocusAppearance(high).MinHeight = %d, want 110", got) } if got := lo.OutlineWidth; got < 2 { t.Fatalf("fieldFocusAppearance(low).OutlineWidth = %d, want >= 2", got) } if hi.OutlineWidth <= lo.OutlineWidth { t.Fatalf("fieldFocusAppearance(high).OutlineWidth = %d, want > %d", hi.OutlineWidth, lo.OutlineWidth) } if lo.OutlineColor == unfocused.OutlineColor { t.Fatalf("fieldFocusAppearance().OutlineColor focused = %#v, want distinct from unfocused %#v", lo.OutlineColor, unfocused.OutlineColor) } } func TestUIActionErrorsAndStatusMessagesAreCapturedForDisplay(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.vaultPath.SetText("/does/not/exist.kdbx") u.masterPassword.SetText("correct horse battery staple") u.runAction("open vault", u.openVaultAction) if u.state.ErrorMessage == "" { t.Fatal("state.ErrorMessage = empty, want visible action error") } u = newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{ {ID: "vault-console", Title: "Vault Console", Username: "dannyocean", Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}}, }, }) u.clipboardWriter = &memoryClipboardWriter{} u.showEntriesSection() u.state.NavigateToPath([]string{"Root", "Internet"}) u.filter() u.state.SelectedEntryID = "vault-console" u.runAction("copy username", func() error { return u.copySelectedFieldAction(clipboard.TargetUsername) }) if u.state.StatusMessage == "" { t.Fatal("state.StatusMessage = empty, want visible success status") } } func TestUIPasswordProfilesAreVisibleInEntryWorkflow(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) got := u.passwordProfileOptionsText() for _, want := range passwords.DefaultProfileNames() { if !strings.Contains(got, want) { t.Fatalf("passwordProfileOptionsText() = %q, want profile %q to be visible", got, want) } } } func TestUIGeneratedPasswordFlowsIntoCreateEntryForm(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.masterPassword.SetText("correct horse battery staple") if err := u.createVaultAction(); err != nil { t.Fatalf("createVaultAction() error = %v", err) } u.showEntriesSection() u.state.NavigateToPath([]string{"Root", "Internet"}) u.filter() u.loadSelectedEntryIntoEditor() u.entryID.SetText("entry-1") u.entryTitle.SetText("Generated Entry") u.entryUsername.SetText("rustyryan") u.entryURL.SetText("https://vault.crew.example.invalid") u.entryPath.SetText("Root / Internet") u.passwordProfile.SetText("memorable") if err := u.generatePasswordAction(); err != nil { t.Fatalf("generatePasswordAction() error = %v", err) } generated := u.entryPassword.Text() if len(generated) < passwords.DefaultProfiles()["memorable"].Length { t.Fatalf("len(entryPassword.Text()) = %d, want at least %d after generate", len(generated), passwords.DefaultProfiles()["memorable"].Length) } if err := u.saveEntryAction(); err != nil { t.Fatalf("saveEntryAction() error = %v", err) } u.state.SelectedEntryID = "entry-1" saved, ok := u.selectedEntry() if !ok { t.Fatal("selectedEntry() ok = false, want true for saved generated entry") } if saved.Password != generated { t.Fatalf("saved.Password = %q, want generated password %q", saved.Password, generated) } } func TestUIGeneratedPasswordDraftStateClearsOnReloadAndSave(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{ { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", Password: "bellagio-pass-1", Path: []string{"Root", "Internet"}, }, }, }) u.showEntriesSection() u.state.NavigateToPath([]string{"Root", "Internet"}) u.filter() u.state.SelectedEntryID = "vault-console" u.loadSelectedEntryIntoEditor() if u.generatedPasswordDraft { t.Fatal("generatedPasswordDraft = true, want false before generating") } u.passwordProfile.SetText("strong") if err := u.generatePasswordAction(); err != nil { t.Fatalf("generatePasswordAction() error = %v", err) } if !u.generatedPasswordDraft { t.Fatal("generatedPasswordDraft = false, want true after generating") } u.loadSelectedEntryIntoEditor() if u.generatedPasswordDraft { t.Fatal("generatedPasswordDraft = true, want false after reloading entry into editor") } u.passwordProfile.SetText("strong") if err := u.generatePasswordAction(); err != nil { t.Fatalf("generatePasswordAction() second call error = %v", err) } if !u.generatedPasswordDraft { t.Fatal("generatedPasswordDraft = false, want true after generating the second time") } if err := u.saveEntryAction(); err != nil { t.Fatalf("saveEntryAction() error = %v", err) } if u.generatedPasswordDraft { t.Fatal("generatedPasswordDraft = true, want false after saving") } } func TestUIBannerSurfacePrefersLoadingThenErrorThenStatus(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{}) u.loadingMessage = "Opening vault..." if got := u.bannerSurface(); got.Kind != bannerLoading || got.Message != "Opening vault..." { t.Fatalf("bannerSurface() with loading = %#v, want loading banner", got) } u.loadingMessage = "" u.state.ErrorMessage = "save failed" if got := u.bannerSurface(); got.Kind != bannerError || got.Message != "save failed" { t.Fatalf("bannerSurface() with error = %#v, want error banner", got) } u.state.ErrorMessage = "" u.state.StatusMessage = "save complete" if got := u.bannerSurface(); got.Kind != bannerNone { t.Fatalf("bannerSurface() with status = %#v, want no status banner", got) } if got := u.statusToastSurface(); got.Kind != bannerStatus || got.Message != "save complete" { t.Fatalf("statusToastSurface() with status = %#v, want status toast", got) } } func TestUIBannerActionLabelsExposeCancelAndRetryForLifecycleOpen(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.loadingMessage = "Open vault..." u.loadingActionLabel = "open vault" primary, secondary := u.bannerActionLabels(u.bannerSurface()) if primary != "Cancel" || secondary != "" { t.Fatalf("bannerActionLabels(loading) = %q/%q, want Cancel/empty", primary, secondary) } u.loadingMessage = "" u.loadingActionLabel = "" u.lastLifecycleAction = "open vault" u.state.ErrorMessage = "open failed" primary, secondary = u.bannerActionLabels(u.bannerSurface()) if primary != "Retry" || secondary != "Dismiss" { t.Fatalf("bannerActionLabels(error) = %q/%q, want Retry/Dismiss", primary, secondary) } } func TestCompactPathDirectorySummaryCollapsesLongPaths(t *testing.T) { t.Parallel() got := compactPathDirectorySummary("/home/julian/vaults/bellagio/main.kdbx") if got != "home/.../bellagio" { t.Fatalf("compactPathDirectorySummary() = %q, want %q", got, "home/.../bellagio") } short := compactPathDirectorySummary("/tmp/main.kdbx") if short != "/tmp" { t.Fatalf("compactPathDirectorySummary(short) = %q, want %q", short, "/tmp") } } func TestUIStatusToastExpiresAfterTimeout(t *testing.T) { t.Parallel() now := time.Date(2026, time.March, 29, 12, 0, 0, 0, time.UTC) u := newUIWithModel("desktop", vault.Model{}) u.now = func() time.Time { return now } u.state.StatusMessage = "synchronize vault complete" if statusBannerDuration != 2600*time.Millisecond { t.Fatalf("statusBannerDuration = %v, want 2.6s", statusBannerDuration) } u.statusExpiresAt = now.Add(statusBannerDuration) if got := u.statusToastSurface(); got.Kind != bannerStatus || got.Message != "synchronize vault complete" { t.Fatalf("statusToastSurface() before expiry = %#v, want visible status toast", got) } now = now.Add(statusBannerDuration + time.Millisecond) if got := u.statusToastSurface(); got.Kind != bannerNone { t.Fatalf("statusToastSurface() after expiry = %#v, want no toast", got) } if got := u.state.StatusMessage; got != "" { t.Fatalf("state.StatusMessage after expiry = %q, want empty", got) } } func TestUIStatusToastExpiresAfterConfiguredTimeout(t *testing.T) { t.Parallel() now := time.Date(2026, time.March, 29, 12, 0, 0, 0, time.UTC) u := newUIWithModel("desktop", vault.Model{}) u.now = func() time.Time { return now } u.statusBannerTTL = statusBannerLong u.state.StatusMessage = "save complete" u.statusExpiresAt = now.Add(u.statusBannerTTL) now = now.Add(statusBannerDuration + time.Second) if got := u.statusToastSurface(); got.Kind != bannerStatus { t.Fatalf("statusToastSurface() before configured expiry = %#v, want visible status toast", got) } now = now.Add(statusBannerLong) if got := u.statusToastSurface(); got.Kind != bannerNone { t.Fatalf("statusToastSurface() after configured expiry = %#v, want no toast", got) } } func TestUIReducedMotionKeepsStatusToastVisible(t *testing.T) { t.Parallel() now := time.Date(2026, time.March, 29, 12, 0, 0, 0, time.UTC) u := newUIWithModel("desktop", vault.Model{}) u.now = func() time.Time { return now } u.statusBannerTTL = statusBannerLong u.applyAccessibilityPreferences(accessibilityPreferences{ReducedMotion: true}) u.showStatusMessage("synchronize vault complete") if !u.statusExpiresAt.IsZero() { t.Fatalf("statusExpiresAt with reduced motion = %v, want zero", u.statusExpiresAt) } now = now.Add(statusBannerLong * 2) if got := u.statusToastSurface(); got.Kind != bannerStatus || got.Message != "synchronize vault complete" { t.Fatalf("statusToastSurface() with reduced motion = %#v, want persistent status toast", got) } } func TestUIAutofillStatusSurfaceUsesPendingApproval(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{}) u.state.Approvals = &mainStubApprovalManager{ pending: []apiapproval.Request{ { ID: "approval-1", TokenName: "Browser Extension", ClientName: "Firefox", Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "entry-1"}, }, }, } got := u.autofillStatusSurface() if got.Kind != autofillStatusAwaitingApproval { t.Fatalf("autofillStatusSurface().Kind = %q, want %q", got.Kind, autofillStatusAwaitingApproval) } if got.Title != "Autofill approval needed" { t.Fatalf("autofillStatusSurface().Title = %q, want %q", got.Title, "Autofill approval needed") } if !strings.Contains(got.Message, "Firefox (Browser Extension)") { t.Fatalf("autofillStatusSurface().Message = %q, want requester details", got.Message) } if got.Detail != "Entry entry-1" { t.Fatalf("autofillStatusSurface().Detail = %q, want %q", got.Detail, "Entry entry-1") } } func TestUIAutofillStatusSurfaceRespectsNoticePreference(t *testing.T) { t.Parallel() now := time.Date(2026, time.March, 29, 12, 0, 0, 0, time.UTC) u := newUIWithModel("desktop", vault.Model{}) u.now = func() time.Time { return now } u.auditLog = &apiaudit.Log{} u.auditLog.Record(apiaudit.Event{ Type: apiaudit.EventAutofillFound, TokenName: "Browser Extension", ClientName: "Firefox", Operation: apitokens.OperationCopyPassword, At: now, }) u.autofillNoticePreference = autofillNoticeApprovals if got := u.autofillStatusSurface(); got.Kind != autofillStatusNone { t.Fatalf("autofillStatusSurface() with approvals-only preference = %#v, want no recent notice", got) } u.autofillNoticePreference = autofillNoticeSuppressed u.state.Approvals = &mainStubApprovalManager{ pending: []apiapproval.Request{{ ID: "approval-1", TokenName: "Browser Extension", ClientName: "Firefox", Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "entry-1"}, }}, } if got := u.autofillStatusSurface(); got.Kind != autofillStatusNone { t.Fatalf("autofillStatusSurface() with suppressed preference = %#v, want no notice", got) } } func TestUIAutofillStatusSurfaceUsesAuditEventsForFoundAmbiguousAndBlocked(t *testing.T) { t.Parallel() now := time.Date(2026, time.March, 31, 12, 0, 0, 0, time.UTC) u := newUIWithModel("desktop", vault.Model{}) u.now = func() time.Time { return now } u.auditLog = apiaudit.New(10) u.auditLog.Record(apiaudit.Event{ Type: apiaudit.EventAutofillFound, At: now, TokenName: "Browser Extension", Message: "Vault Console is ready to fill.", Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console"}, }) if got := u.autofillStatusSurface(); got.Kind != autofillStatusFound || got.Title != "Autofill match ready" { t.Fatalf("autofillStatusSurface(found) = %#v, want found status", got) } u.auditLog = apiaudit.New(10) u.auditLog.Record(apiaudit.Event{ Type: apiaudit.EventAutofillAmbiguous, At: now, TokenName: "Browser Extension", Message: "Multiple entries match example.com.", }) if got := u.autofillStatusSurface(); got.Kind != autofillStatusAmbiguous || got.Title != "Autofill needs a narrower match" { t.Fatalf("autofillStatusSurface(ambiguous) = %#v, want ambiguous status", got) } u.auditLog = apiaudit.New(10) u.auditLog.Record(apiaudit.Event{ Type: apiaudit.EventApprovalDenied, At: now, TokenName: "Browser Extension", ClientName: "Firefox", Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console"}, }) if got := u.autofillStatusSurface(); got.Kind != autofillStatusBlocked || got.Title != "Autofill was not allowed" { t.Fatalf("autofillStatusSurface(blocked) = %#v, want blocked status", got) } } func TestUIAutofillStatusSurfaceIgnoresExpiredAndNonAutofillAuditEvents(t *testing.T) { t.Parallel() now := time.Date(2026, time.March, 31, 12, 0, 0, 0, time.UTC) u := newUIWithModel("desktop", vault.Model{}) u.now = func() time.Time { return now } u.auditLog = apiaudit.New(10) u.auditLog.Record(apiaudit.Event{ Type: apiaudit.EventAutofillFound, At: now.Add(-autofillStatusTTL - time.Second), TokenName: "Browser Extension", Message: "stale event", }) if got := u.autofillStatusSurface(); got.Kind != autofillStatusNone { t.Fatalf("autofillStatusSurface(stale) = %#v, want none", got) } u.auditLog = apiaudit.New(10) u.auditLog.Record(apiaudit.Event{ Type: apiaudit.EventApprovalAllowed, At: now, TokenName: "CLI", Operation: apitokens.OperationListEntries, Message: "not autofill", }) if got := u.autofillStatusSurface(); got.Kind != autofillStatusNone { t.Fatalf("autofillStatusSurface(non-autofill) = %#v, want none", got) } } func TestUIRunActionNormalizesRemoteSaveConflictsForDisplay(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{}) u.runAction("save vault", func() error { return errors.New("save remote vaults/main.kdbx: " + webdav.ErrConflict.Error()) }) if got := u.state.ErrorMessage; got != "Save conflict: the remote vault changed. Reopen it and retry the save." { t.Fatalf("state.ErrorMessage = %q, want normalized save conflict guidance", got) } if got := u.state.StatusMessage; got != "" { t.Fatalf("state.StatusMessage = %q, want empty on conflict", got) } } func TestUIUsesKeePassGOProductCopy(t *testing.T) { t.Parallel() if productName != "KeePassGO" { t.Fatalf("productName = %q, want %q", productName, "KeePassGO") } } func TestUIShowsLifecycleSetupOnlyBeforeVaultIsOpened(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) if !u.shouldShowLifecycleSetup() { t.Fatal("shouldShowLifecycleSetup() = false, want true before opening a vault") } u.masterPassword.SetText("correct horse battery staple") if err := u.createVaultAction(); err != nil { t.Fatalf("createVaultAction() error = %v", err) } if u.shouldShowLifecycleSetup() { t.Fatal("shouldShowLifecycleSetup() = true, want false after opening a vault") } } func TestUIPendingApprovalUsesFirstPendingRequest(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{}) u.state.Approvals = &mainStubApprovalManager{ pending: []apiapproval.Request{ {ID: "approval-1", TokenName: "CLI", Operation: apitokens.OperationListEntries}, {ID: "approval-2", TokenName: "Browser", Operation: apitokens.OperationReadEntry}, }, } request, ok := u.pendingApproval() if !ok { t.Fatal("pendingApproval() ok = false, want true") } if request.ID != "approval-1" { t.Fatalf("pendingApproval().ID = %q, want approval-1", request.ID) } } func TestUIResolvePendingApprovalDelegatesToApprovalManager(t *testing.T) { t.Parallel() manager := &mainStubApprovalManager{ pending: []apiapproval.Request{{ID: "approval-1"}}, } u := newUIWithModel("desktop", vault.Model{}) u.state.Approvals = manager if err := u.resolvePendingApproval(apiapproval.OutcomeDenyPermanent); err != nil { t.Fatalf("resolvePendingApproval() error = %v", err) } if manager.lastID != "approval-1" || manager.lastOutcome != apiapproval.OutcomeDenyPermanent { t.Fatalf("resolvePendingApproval() delegated (%q, %q), want (approval-1, deny-permanent)", manager.lastID, manager.lastOutcome) } } func TestUIRequiresExplicitEditModeForEntryEditor(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{{ID: "1", Title: "Vault Console", Path: []string{"Root"}}}, }) u.filter() u.state.SelectedEntryID = "1" if u.editingEntry { t.Fatal("editingEntry = true, want false by default") } u.editingEntry = true u.loadSelectedEntryIntoEditor() if !u.editingEntry { t.Fatal("editingEntry = false, want true after entering edit mode") } } func TestUIAutoEntersSingleVaultRootGroupAndDisplaysSlashRoot(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "keepass.kdbx") var encoded bytes.Buffer if err := vault.SaveKDBX(&encoded, vault.Model{ Entries: []vault.Entry{ {ID: "vault-console", Title: "Vault Console", Path: []string{"keepass", "Crew", "Internet"}}, }, }, "correct horse battery staple"); err != nil { t.Fatalf("SaveKDBX() error = %v", err) } if err := os.WriteFile(path, encoded.Bytes(), 0o600); err != nil { t.Fatalf("WriteFile(keepass.kdbx) error = %v", err) } u := newUIWithSession("desktop", &session.Manager{}) u.masterPassword.SetText("correct horse battery staple") u.vaultPath.SetText(path) if err := u.openVaultAction(); err != nil { t.Fatalf("openVaultAction() error = %v", err) } if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) { t.Fatalf("currentPath = %v, want [keepass]", got) } if got := u.displayPath(); len(got) != 0 { t.Fatalf("displayPath() = %v, want root slash path", got) } if got := u.childGroups(); !slices.Equal(got, []string{"Crew"}) { t.Fatalf("childGroups() = %v, want [Crew]", got) } } func TestUIShowEntriesSectionRestoresHiddenRootAfterLeavingEntries(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{ {ID: "1", Title: "Vault Console", Path: []string{"keepass", "Crew", "Internet"}}, {ID: "2", Title: "Security Office", Path: []string{"keepass", "Crew", "Safe House"}}, }, }) u.showEntriesSection() if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) { t.Fatalf("currentPath after initial entries section = %v, want [keepass]", got) } u.showAPITokensSection() u.showEntriesSection() if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) { t.Fatalf("currentPath after returning to entries = %v, want [keepass]", got) } if got := u.displayPath(); len(got) != 0 { t.Fatalf("displayPath() after returning to entries = %v, want root slash path", got) } if got := u.childGroups(); !slices.Equal(got, []string{"Crew"}) { t.Fatalf("childGroups() after returning to entries = %v, want [Crew]", got) } } func TestUIShowEntriesSectionRestoresEntriesViewState(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{ {ID: "amazon", Title: "Amazon", Username: "danny@crew.example.invalid", Path: []string{"keepass", "Crew", "Internet"}}, {ID: "aws", Title: "Amazon AWS", Username: "danny@crew.example.invalid", Path: []string{"keepass", "Crew", "Internet"}}, {ID: "git", Title: "Vault Console", Username: "dannyocean", Path: []string{"keepass", "Crew", "Internet"}}, }, }) u.showEntriesSection() u.setCurrentPath([]string{"keepass", "Crew", "Internet"}) u.search.SetText("amazon") u.filter() u.state.SelectedEntryID = "amazon" u.editingEntry = true u.loadSelectedEntryIntoEditor() u.showAPITokensSection() u.showEntriesSection() if got := u.currentPath; !slices.Equal(got, []string{"keepass", "Crew", "Internet"}) { t.Fatalf("currentPath after returning to entries = %v, want [keepass Crew Internet]", got) } if got := u.search.Text(); got != "amazon" { t.Fatalf("search text after returning to entries = %q, want amazon", got) } if got := u.state.SelectedEntryID; got != "amazon" { t.Fatalf("SelectedEntryID after returning to entries = %q, want amazon", got) } if !u.editingEntry { t.Fatal("editingEntry = false, want true after returning to entries") } if got := u.filteredTitles(); !slices.Equal(got, []string{"Amazon", "Amazon AWS"}) { t.Fatalf("filteredTitles() after returning to entries = %v, want [Amazon Amazon AWS]", got) } } func TestUINoteRecentVaultDeduplicatesAndOrdersMostRecentFirst(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.recentVaultsPath = filepath.Join(t.TempDir(), "recent-vaults.json") u.recentVaults = nil u.noteRecentVault("/tmp/one.kdbx") u.noteRecentVault("/tmp/two.kdbx") u.noteRecentVault("/tmp/one.kdbx") if got := u.recentVaults; !slices.Equal(got, []string{"/tmp/one.kdbx", "/tmp/two.kdbx"}) { t.Fatalf("recentVaults = %v, want [/tmp/one.kdbx /tmp/two.kdbx]", got) } } func TestUILoadsRecentVaultsFromPersistedConfig(t *testing.T) { t.Parallel() configPath := filepath.Join(t.TempDir(), "recent-vaults.json") first := newUIWithSession("desktop", &session.Manager{}) first.recentVaultsPath = configPath first.recentVaults = nil first.noteRecentVault("/tmp/one.kdbx") first.noteRecentVault("/tmp/two.kdbx") second := newUIWithSession("desktop", &session.Manager{}) second.recentVaultsPath = configPath second.recentVaults = nil second.loadRecentVaults() if got := second.recentVaults; !slices.Equal(got, []string{"/tmp/two.kdbx", "/tmp/one.kdbx"}) { t.Fatalf("recentVaults after reload = %v, want [/tmp/two.kdbx /tmp/one.kdbx]", got) } } func TestUIStartupPreselectsMostRecentLocalVault(t *testing.T) { t.Parallel() configPath := filepath.Join(t.TempDir(), "recent-vaults.json") first := newUIWithSession("desktop", &session.Manager{}) first.recentVaultsPath = configPath first.recentVaults = nil first.recentVaultUsedAt = map[string]time.Time{} first.now = func() time.Time { return time.Date(2026, 3, 30, 12, 0, 0, 0, time.UTC) } first.noteRecentVault("/tmp/older.kdbx") first.now = func() time.Time { return time.Date(2026, 3, 30, 13, 0, 0, 0, time.UTC) } first.noteRecentVault("/tmp/newer.kdbx") second := newUIWithSession("desktop", &session.Manager{}, statePaths{ DefaultSaveAsPath: filepath.Join(t.TempDir(), "vault.kdbx"), RecentVaultsPath: configPath, RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), }) if got := second.lifecycleMode; got != "local" { t.Fatalf("lifecycleMode = %q, want local", got) } if got := second.vaultPath.Text(); got != "/tmp/newer.kdbx" { t.Fatalf("vaultPath = %q, want /tmp/newer.kdbx", got) } } func TestUIRecentVaultsPersistLastOpenedGroupPerVault(t *testing.T) { t.Parallel() configPath := filepath.Join(t.TempDir(), "recent-vaults.json") first := newUIWithSession("desktop", &session.Manager{}) first.recentVaultsPath = configPath first.recentVaults = nil first.currentPath = []string{"Root", "Internet"} first.syncedPath = []string{"Root", "Internet"} first.noteRecentVault("/tmp/one.kdbx") first.currentPath = []string{"Root", "Security Office"} first.syncedPath = []string{"Root", "Security Office"} first.noteRecentVault("/tmp/two.kdbx") first.currentPath = []string{"Root", "Finance"} first.syncedPath = []string{"Root", "Finance"} first.noteRecentVault("/tmp/one.kdbx") second := newUIWithSession("desktop", &session.Manager{}) second.recentVaultsPath = configPath second.recentVaults = nil second.loadRecentVaults() if got := second.recentVaults; !slices.Equal(got, []string{"/tmp/one.kdbx", "/tmp/two.kdbx"}) { t.Fatalf("recentVaults after reload = %v, want [/tmp/one.kdbx /tmp/two.kdbx]", got) } if got := second.recentVaultGroup("/tmp/one.kdbx"); !slices.Equal(got, []string{"Root", "Finance"}) { t.Fatalf("recentVaultGroup(one) = %v, want [Root Finance]", got) } if got := second.recentVaultGroup("/tmp/two.kdbx"); !slices.Equal(got, []string{"Root", "Security Office"}) { t.Fatalf("recentVaultGroup(two) = %v, want [Root Security Office]", got) } } func TestUIOpenVaultRestoresLastOpenedGroupForThatVault(t *testing.T) { t.Parallel() dir := t.TempDir() path := filepath.Join(dir, "keepass.kdbx") statePath := filepath.Join(dir, "recent-vaults.json") u := newUIWithSession("desktop", &session.Manager{}) u.recentVaultsPath = statePath u.masterPassword.SetText("correct horse battery staple") if err := u.createVaultAction(); err != nil { t.Fatalf("createVaultAction() error = %v", err) } if err := u.state.UpsertEntry(vault.Entry{ ID: "entry-1", Title: "Vault Console", Username: "dannyocean", Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }); err != nil { t.Fatalf("UpsertEntry() error = %v", err) } u.state.NavigateToPath([]string{"Root", "Internet"}) u.currentPath = []string{"Root", "Internet"} u.syncedPath = []string{"Root", "Internet"} u.saveAsPath.SetText(path) if err := u.saveAsAction(); err != nil { t.Fatalf("saveAsAction() error = %v", err) } reopened := newUIWithSession("desktop", &session.Manager{}) reopened.recentVaultsPath = statePath reopened.recentVaults = nil reopened.loadRecentVaults() reopened.masterPassword.SetText("correct horse battery staple") reopened.vaultPath.SetText(path) if err := reopened.openVaultAction(); err != nil { t.Fatalf("openVaultAction() error = %v", err) } if got := reopened.displayPath(); !slices.Equal(got, []string{"Internet"}) { t.Fatalf("displayPath() after reopen = %v, want [Internet]", got) } if got := reopened.state.CurrentPath; !slices.Equal(got, []string{"Root", "Internet"}) { t.Fatalf("state.CurrentPath after reopen = %v, want [Root Internet]", got) } } func TestUIRecentRemoteConnectionsPersistAndReload(t *testing.T) { t.Parallel() configPath := filepath.Join(t.TempDir(), "recent-remotes.json") first := newUIWithSession("desktop", &session.Manager{}) first.recentRemotesPath = configPath first.recentRemotes = nil first.currentPath = []string{"Root", "Internet"} first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx") first.currentPath = []string{"Root", "Safe House"} first.noteRecentRemote("https://dav.example.com", "vaults/team.kdbx") first.currentPath = []string{"Root", "Finance"} first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx") second := newUIWithSession("desktop", &session.Manager{}) second.recentRemotesPath = configPath second.recentRemotes = nil second.loadRecentRemotes() if got := len(second.recentRemotes); got != 2 { t.Fatalf("len(recentRemotes) = %d, want 2", got) } if got := second.recentRemotes[0]; got.BaseURL != "https://dav.example.com" || got.Path != "vaults/home.kdbx" { t.Fatalf("recentRemotes[0] = %#v, want updated location-only record", got) } if got := second.recentRemotes[0].LastGroup; !slices.Equal(got, []string{"Root", "Finance"}) { t.Fatalf("recentRemotes[0].LastGroup = %v, want [Root Finance]", got) } if got := second.recentRemotes[1].LastGroup; !slices.Equal(got, []string{"Root", "Safe House"}) { t.Fatalf("recentRemotes[1].LastGroup = %v, want [Root Safe House]", got) } } func TestUIRecentRemoteConnectionsPersistVaultBindingMetadata(t *testing.T) { t.Parallel() configPath := filepath.Join(t.TempDir(), "recent-remotes.json") first := newUIWithSession("desktop", &session.Manager{}) first.recentRemotesPath = configPath first.recentRemotes = nil first.currentPath = []string{"Root", "Internet"} first.vaultPath.SetText("/vaults/bellagio.kdbx") first.selectedVaultRemoteProfileID = "remote-profile-1" first.selectedVaultRemoteCredentialEntryID = "remote-creds-1" first.selectedVaultRemoteSyncMode = appstate.SyncModeAutomaticOnOpenSave first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx") second := newUIWithSession("desktop", &session.Manager{}) second.recentRemotesPath = configPath second.recentRemotes = nil second.loadRecentRemotes() if got := len(second.recentRemotes); got != 1 { t.Fatalf("len(recentRemotes) = %d, want 1", got) } record := second.recentRemotes[0] if record.LocalVaultPath != "/vaults/bellagio.kdbx" { t.Fatalf("recentRemotes[0].LocalVaultPath = %q, want /vaults/bellagio.kdbx", record.LocalVaultPath) } if record.RemoteProfileID != "remote-profile-1" { t.Fatalf("recentRemotes[0].RemoteProfileID = %q, want remote-profile-1", record.RemoteProfileID) } if record.CredentialEntryID != "remote-creds-1" { t.Fatalf("recentRemotes[0].CredentialEntryID = %q, want remote-creds-1", record.CredentialEntryID) } if record.SyncMode != string(appstate.SyncModeAutomaticOnOpenSave) { t.Fatalf("recentRemotes[0].SyncMode = %q, want automatic_on_open_save", record.SyncMode) } } func TestUILoadRecentRemotesIgnoresLegacySavedCredentials(t *testing.T) { t.Parallel() configPath := filepath.Join(t.TempDir(), "recent-remotes.json") content := `[ { "baseUrl": "https://dav.example.com", "path": "vaults/home.kdbx", "username": "debbieocean", "password": "secret-1", "lastGroup": ["Root", "Internet"] } ]` if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil { t.Fatalf("WriteFile(recent-remotes.json) error = %v", err) } u := newUIWithSession("desktop", &session.Manager{}) u.recentRemotesPath = configPath u.recentRemotes = nil u.loadRecentRemotes() if got := len(u.recentRemotes); got != 1 { t.Fatalf("len(recentRemotes) = %d, want 1", got) } if got := u.recentRemotes[0]; got.BaseURL != "https://dav.example.com" || got.Path != "vaults/home.kdbx" { t.Fatalf("recentRemotes[0] = %#v, want location-only record", got) } if !u.recentRemotes[0].NeedsMigration { t.Fatal("recentRemotes[0].NeedsMigration = false, want true for legacy saved credentials") } if got := u.recentRemotes[0].Username; got != "" { t.Fatalf("recentRemotes[0].Username = %q, want empty after migration strip", got) } if got := u.recentRemotes[0].Password; got != "" { t.Fatalf("recentRemotes[0].Password = %q, want empty after migration strip", got) } } func TestUINewUIShowsMigrationStatusForLegacyRecentRemoteCredentials(t *testing.T) { t.Parallel() dir := t.TempDir() recentRemotesPath := filepath.Join(dir, "recent-remotes.json") content := `[ { "baseUrl": "https://dav.example.com", "path": "vaults/home.kdbx", "username": "debbieocean", "password": "secret-1" } ]` if err := os.WriteFile(recentRemotesPath, []byte(content), 0o600); err != nil { t.Fatalf("WriteFile(recent-remotes.json) error = %v", err) } u := newUIWithState("desktop", &session.Manager{}, statePaths{ DefaultSaveAsPath: filepath.Join(dir, "default.kdbx"), RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), RecentRemotesPath: recentRemotesPath, UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), }) if got := u.state.StatusMessage; got != "This saved remote came from an older local-sign-in format. Open it again, then save the remote in the vault to migrate it." { t.Fatalf("StatusMessage = %q, want legacy recent-remote migration notice for the selected startup remote", got) } } func TestUIApplyRecentRemoteRecordRestoresVaultBindingSelection(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.applyRecentRemoteRecord(recentRemoteRecord{ BaseURL: "https://dav.example.com", Path: "vaults/home.kdbx", LocalVaultPath: "/vaults/bellagio.kdbx", RemoteProfileID: "remote-profile-1", CredentialEntryID: "remote-creds-1", SyncMode: string(appstate.SyncModeAutomaticOnOpenSave), }) if got := u.vaultPath.Text(); got != "/vaults/bellagio.kdbx" { t.Fatalf("vaultPath = %q, want /vaults/bellagio.kdbx", got) } if got := u.selectedVaultRemoteProfileID; got != "remote-profile-1" { t.Fatalf("selectedVaultRemoteProfileID = %q, want remote-profile-1", got) } if got := u.selectedVaultRemoteCredentialEntryID; got != "remote-creds-1" { t.Fatalf("selectedVaultRemoteCredentialEntryID = %q, want remote-creds-1", got) } if got := u.selectedVaultRemoteSyncMode; got != appstate.SyncModeAutomaticOnOpenSave { t.Fatalf("selectedVaultRemoteSyncMode = %q, want automatic_on_open_save", got) } } func TestUIApplyRecentRemoteRecordShowsMigrationNoticeForLegacySavedCredentials(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.applyRecentRemoteRecord(recentRemoteRecord{ BaseURL: "https://dav.example.com", Path: "vaults/home.kdbx", NeedsMigration: true, }) if got := u.state.StatusMessage; got != "This saved remote came from an older local-sign-in format. Open it again, then save the remote in the vault to migrate it." { t.Fatalf("StatusMessage = %q, want legacy per-record migration notice", got) } } func TestUIStartupPreselectsNewestTargetAcrossLocalAndRemote(t *testing.T) { t.Parallel() dir := t.TempDir() vaultsPath := filepath.Join(dir, "recent-vaults.json") remotesPath := filepath.Join(dir, "recent-remotes.json") paths := statePaths{ DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), RecentVaultsPath: vaultsPath, RecentRemotesPath: remotesPath, UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), } first := newUIWithSession("desktop", &session.Manager{}, paths) first.now = func() time.Time { return time.Date(2026, 3, 30, 12, 0, 0, 0, time.UTC) } first.noteRecentVault("/tmp/local.kdbx") first.now = func() time.Time { return time.Date(2026, 3, 30, 13, 0, 0, 0, time.UTC) } first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx") second := newUIWithSession("desktop", &session.Manager{}, paths) if got := second.lifecycleMode; got != "local" { t.Fatalf("lifecycleMode = %q, want local", got) } if got := second.vaultPath.Text(); got != "/tmp/local.kdbx" { t.Fatalf("vaultPath = %q, want /tmp/local.kdbx", got) } if got := second.remoteUsername.Text(); got != "" { t.Fatalf("remoteUsername = %q, want empty for location-only recent remote", got) } if got := second.remotePassword.Text(); got != "" { t.Fatalf("remotePassword = %q, want empty for location-only recent remote", got) } } func TestUIStartupDoesNotRequestMasterPasswordFocusWithoutSelectedTarget(t *testing.T) { t.Parallel() dir := t.TempDir() u := newUIWithSession("phone", &session.Manager{}, statePaths{ DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), SettingsPath: filepath.Join(dir, "settings.json"), UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), AutofillCachePath: filepath.Join(dir, "autofill-cache.json"), }) if u.requestMasterPassFocus { t.Fatal("requestMasterPassFocus = true without a selected startup target, want false") } } func TestUIStartupRequestsMasterPasswordFocusForSelectedRecentVault(t *testing.T) { t.Parallel() dir := t.TempDir() paths := statePaths{ DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), SettingsPath: filepath.Join(dir, "settings.json"), UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), AutofillCachePath: filepath.Join(dir, "autofill-cache.json"), } first := newUIWithSession("phone", &session.Manager{}, paths) first.noteRecentVault("/tmp/demo.kdbx") reopened := newUIWithSession("phone", &session.Manager{}, paths) if got := reopened.vaultPath.Text(); got != "/tmp/demo.kdbx" { t.Fatalf("vaultPath = %q, want /tmp/demo.kdbx", got) } if !reopened.requestMasterPassFocus { t.Fatal("requestMasterPassFocus = false with a selected startup vault, want true") } } func TestUIGroupToolsDisclosureStatePersists(t *testing.T) { t.Parallel() configPath := filepath.Join(t.TempDir(), "ui-prefs.json") first := newUIWithSession("desktop", &session.Manager{}, statePaths{ UIPreferencesPath: configPath, }) first.groupControlsHidden = true first.saveUIPreferences() second := newUIWithSession("desktop", &session.Manager{}, statePaths{ UIPreferencesPath: configPath, }) second.groupControlsHidden = false second.loadUIPreferences() if !second.groupControlsHidden { t.Fatal("groupControlsHidden = false after reload, want true") } } func TestUIDenseLayoutPreferencePersists(t *testing.T) { t.Parallel() configPath := filepath.Join(t.TempDir(), "ui-prefs.json") first := newUIWithSession("desktop", &session.Manager{}, statePaths{ UIPreferencesPath: configPath, }) first.denseLayout = true first.saveUIPreferences() second := newUIWithSession("desktop", &session.Manager{}, statePaths{ UIPreferencesPath: configPath, }) second.denseLayout = false second.loadUIPreferences() if !second.denseLayout { t.Fatal("denseLayout = false after reload, want true") } } func TestUISyncDefaultsPersistInSettings(t *testing.T) { t.Parallel() configPath := filepath.Join(t.TempDir(), "settings.json") first := newUIWithSession("desktop", &session.Manager{}, statePaths{ SettingsPath: configPath, }) first.syncDefaultSourceMode = syncSourceRemote first.syncDefaultDirection = syncDirectionPush first.saveSettings() second := newUIWithSession("desktop", &session.Manager{}, statePaths{ SettingsPath: configPath, }) second.syncDefaultSourceMode = syncSourceLocal second.syncDefaultDirection = syncDirectionPull second.loadSettings() if got := second.syncDefaultSourceMode; got != syncSourceRemote { t.Fatalf("syncDefaultSourceMode = %q, want remote", got) } if got := second.syncDefaultDirection; got != syncDirectionPush { t.Fatalf("syncDefaultDirection = %q, want push", got) } } func TestUILoadSettingsFallsBackToLegacySyncDefaultsInUIPreferences(t *testing.T) { t.Parallel() dir := t.TempDir() legacyPath := filepath.Join(dir, "ui-prefs.json") content, err := json.MarshalIndent(legacySyncPreferences{ SyncSourceDefault: string(syncSourceRemote), SyncDirectionDefault: string(syncDirectionPush), }, "", " ") if err != nil { t.Fatalf("json.MarshalIndent() error = %v", err) } if err := os.WriteFile(legacyPath, content, 0o600); err != nil { t.Fatalf("os.WriteFile() error = %v", err) } reloaded := newUIWithSession("desktop", &session.Manager{}, statePaths{ SettingsPath: filepath.Join(dir, "settings.json"), UIPreferencesPath: legacyPath, }) reloaded.syncDefaultSourceMode = syncSourceLocal reloaded.syncDefaultDirection = syncDirectionPull reloaded.loadSettings() if got := reloaded.syncDefaultSourceMode; got != syncSourceRemote { t.Fatalf("syncDefaultSourceMode = %q after legacy load, want remote", got) } if got := reloaded.syncDefaultDirection; got != syncDirectionPush { t.Fatalf("syncDefaultDirection = %q after legacy load, want push", got) } } func TestUIOpenAdvancedSyncDialogUsesSavedSyncDefaults(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.syncDefaultSourceMode = syncSourceRemote u.syncDefaultDirection = syncDirectionPush u.syncSourceMode = syncSourceLocal u.syncDirection = syncDirectionPull u.vaultPath.SetText("/vaults/current.kdbx") u.openAdvancedSyncDialog() if got := u.syncSourceMode; got != syncSourceRemote { t.Fatalf("syncSourceMode = %q after open, want remote default", got) } if got := u.syncDirection; got != syncDirectionPush { t.Fatalf("syncDirection = %q after open, want push default", got) } if got := u.syncLocalPath.Text(); got != "/vaults/current.kdbx" { t.Fatalf("syncLocalPath = %q after open, want current vault path", got) } } func TestUISaveSecuritySettingsPersistsSyncDefaults(t *testing.T) { t.Parallel() manager := &session.Manager{} dir := t.TempDir() u := newUIWithSession("desktop", manager, statePaths{ DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), SettingsPath: filepath.Join(dir, "settings.json"), UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), }) u.masterPassword.SetText("correct horse battery staple") if err := u.createVaultAction(); err != nil { t.Fatalf("createVaultAction() error = %v", err) } u.securityCipher.SetText(vault.CipherAES256) u.securityKDF.SetText(vault.KDFAES) u.loadSettingsDraft() u.settingsDraft.Sync.SourceDefault = syncSourceRemote u.settingsDraft.Sync.DirectionDefault = syncDirectionPush if err := u.saveSecuritySettingsAction(); err != nil { t.Fatalf("saveSecuritySettingsAction() error = %v", err) } reloaded := newUIWithSession("desktop", &session.Manager{}, statePaths{ SettingsPath: u.settingsPath, }) reloaded.loadSettings() if got := reloaded.syncDefaultSourceMode; got != syncSourceRemote { t.Fatalf("reloaded syncDefaultSourceMode = %q, want remote", got) } if got := reloaded.syncDefaultDirection; got != syncDirectionPush { t.Fatalf("reloaded syncDefaultDirection = %q, want push", got) } } func TestUIAccessibilityPreferencesPersist(t *testing.T) { t.Parallel() configPath := filepath.Join(t.TempDir(), "ui-prefs.json") first := newUIWithSession("desktop", &session.Manager{}, statePaths{ UIPreferencesPath: configPath, }) first.applyAccessibilityPreferences(accessibilityPreferences{ DisplayDensity: displayDensityComfortable, Contrast: contrastHigh, ReducedMotion: true, KeyboardFocus: keyboardFocusProminent, }) first.saveUIPreferences() second := newUIWithSession("desktop", &session.Manager{}, statePaths{ UIPreferencesPath: configPath, }) second.loadUIPreferences() if second.denseLayout { t.Fatal("denseLayout after reload = true, want comfortable layout preference") } if got := second.accessibilityPrefs; got != (accessibilityPreferences{ DisplayDensity: displayDensityComfortable, Contrast: contrastHigh, ReducedMotion: true, KeyboardFocus: keyboardFocusProminent, }) { t.Fatalf("accessibilityPrefs after reload = %#v, want comfortable/high/reduced/prominent", got) } } func TestFieldFocusAppearanceUsesAccessibilityPreferences(t *testing.T) { t.Parallel() metric := unit.Metric{PxPerDp: 1, PxPerSp: 1} base := fieldFocusAppearance(metric, defaultAccessibilityPreferences(), true) comfortable := fieldFocusAppearance(metric, accessibilityPreferences{ DisplayDensity: displayDensityComfortable, Contrast: contrastHigh, KeyboardFocus: keyboardFocusProminent, }, true) if comfortable.MinHeight <= base.MinHeight { t.Fatalf("fieldFocusAppearance(comfortable).MinHeight = %d, want > %d", comfortable.MinHeight, base.MinHeight) } if comfortable.OutlineWidth <= base.OutlineWidth { t.Fatalf("fieldFocusAppearance(prominent).OutlineWidth = %d, want > %d", comfortable.OutlineWidth, base.OutlineWidth) } if comfortable.OutlineColor.A <= base.OutlineColor.A { t.Fatalf("fieldFocusAppearance(high contrast).OutlineColor.A = %d, want > %d", comfortable.OutlineColor.A, base.OutlineColor.A) } } func TestUIEntryRowMetricsUseDenseLayout(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) comfortableInset, comfortableTitle, _, _, _, comfortableGap := u.entryRowMetrics() u.denseLayout = true denseInset, denseTitle, _, _, _, denseGap := u.entryRowMetrics() if denseInset >= comfortableInset { t.Fatalf("dense inset = %v, want smaller than comfortable inset %v", denseInset, comfortableInset) } if denseTitle >= comfortableTitle { t.Fatalf("dense title size = %v, want smaller than comfortable title size %v", denseTitle, comfortableTitle) } if denseGap >= comfortableGap { t.Fatalf("dense divider gap = %v, want smaller than comfortable divider gap %v", denseGap, comfortableGap) } } func TestUINotificationPreferencesPersist(t *testing.T) { t.Parallel() configPath := filepath.Join(t.TempDir(), "ui-prefs.json") first := newUIWithSession("desktop", &session.Manager{}, statePaths{ UIPreferencesPath: configPath, }) first.statusBannerTTL = statusBannerLong first.autofillNoticePreference = autofillNoticeApprovals first.saveUIPreferences() second := newUIWithSession("desktop", &session.Manager{}, statePaths{ UIPreferencesPath: configPath, }) second.statusBannerTTL = statusBannerDuration second.autofillNoticePreference = autofillNoticeAll second.loadUIPreferences() if got := second.statusBannerTTL; got != statusBannerLong { t.Fatalf("statusBannerTTL after reload = %v, want %v", got, statusBannerLong) } if got := second.autofillNoticePreference; got != autofillNoticeApprovals { t.Fatalf("autofillNoticePreference after reload = %q, want %q", got, autofillNoticeApprovals) } } func TestAutofillPrivacyLinesNormalizesEntries(t *testing.T) { t.Parallel() got := autofillPrivacyLines(" com.android.chrome \n\ncom.example.app\ncom.android.chrome\n org.keepassgo.browser ") want := []string{"com.android.chrome", "com.example.app", "org.keepassgo.browser"} if !slices.Equal(got, want) { t.Fatalf("autofillPrivacyLines() = %v, want %v", got, want) } } func TestJoinAutofillPrivacyLines(t *testing.T) { t.Parallel() got := joinAutofillPrivacyLines([]string{"com.android.chrome", "com.example.app"}) if got != "com.android.chrome\ncom.example.app" { t.Fatalf("joinAutofillPrivacyLines() = %q, want %q", got, "com.android.chrome\ncom.example.app") } } func TestUIAutofillPrivacyPreferencesPersist(t *testing.T) { t.Parallel() configPath := filepath.Join(t.TempDir(), "ui-prefs.json") first := newUIWithSession("desktop", &session.Manager{}) first.uiPreferencesPath = configPath first.autofillFirstFillApprovalMode = autofillFirstFillApprovalBlock first.autofillBrowserAllowlist.SetText("https://accounts.example.com\nhttps://login.example.org\nhttps://accounts.example.com") first.autofillAppAllowlist.SetText("org.mozilla.firefox\ncom.android.chrome") first.autofillPackageRules.SetText("com.android.chrome=hostname\norg.keepassgo.browser=view-id") first.saveUIPreferences() second := newUIWithSession("desktop", &session.Manager{}) second.uiPreferencesPath = configPath second.autofillFirstFillApprovalMode = autofillFirstFillApprovalAsk second.loadUIPreferences() if got := second.autofillFirstFillApprovalMode; got != autofillFirstFillApprovalBlock { t.Fatalf("autofillFirstFillApprovalMode = %q, want %q", got, autofillFirstFillApprovalBlock) } if got := second.autofillBrowserAllowlist.Text(); got != "https://accounts.example.com\nhttps://login.example.org" { t.Fatalf("autofillBrowserAllowlist = %q, want normalized browser allowlist", got) } if got := second.autofillAppAllowlist.Text(); got != "org.mozilla.firefox\ncom.android.chrome" { t.Fatalf("autofillAppAllowlist = %q, want preserved allowlist entries", got) } if got := second.autofillPackageRules.Text(); got != "com.android.chrome=hostname\norg.keepassgo.browser=view-id" { t.Fatalf("autofillPackageRules = %q, want persisted package rules", got) } } func TestUILoadUIPreferencesKeepsDefaultAutofillApprovalWhenMissing(t *testing.T) { t.Parallel() configPath := filepath.Join(t.TempDir(), "ui-prefs.json") content, err := json.Marshal(uiPreferences{ GroupControlsHidden: true, LifecycleAdvancedHidden: true, HistoryHidden: true, }) if err != nil { t.Fatalf("Marshal(uiPreferences) error = %v", err) } if err := os.WriteFile(configPath, content, 0o600); err != nil { t.Fatalf("WriteFile(uiPreferences) error = %v", err) } u := newUIWithSession("desktop", &session.Manager{}) u.uiPreferencesPath = configPath u.autofillFirstFillApprovalMode = autofillFirstFillApprovalAsk u.loadUIPreferences() if got := u.autofillFirstFillApprovalMode; got != autofillFirstFillApprovalAsk { t.Fatalf("autofillFirstFillApprovalMode = %q, want %q when preference missing", got, autofillFirstFillApprovalAsk) } } func TestSelectingRecentRemoteConnectionKeepsPasswordMasked(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.recentRemotes = []recentRemoteRecord{{ BaseURL: "https://dav.example.com", Path: "vaults/home.kdbx", }} u.recentRemoteClicks = make([]widget.Clickable, 1) u.remoteUsername.SetText("debbieocean") u.remotePassword.SetText("secret-1") u.remotePassword.Mask = 0 u.recentRemoteClicks[0].Click() gtx := layout.Context{} for u.recentRemoteClicks[0].Clicked(gtx) { record := u.recentRemotes[0] u.remoteBaseURL.SetText(record.BaseURL) u.remotePath.SetText(record.Path) u.remotePassword.Mask = '•' } if got := u.remotePassword.Mask; got != '•' { t.Fatalf("remotePassword.Mask = %q, want bullet mask", got) } if got := u.remoteUsername.Text(); got != "debbieocean" { t.Fatalf("remoteUsername = %q, want preserved manual username", got) } if got := u.remotePassword.Text(); got != "secret-1" { t.Fatalf("remotePassword = %q, want preserved manual password", got) } } func TestSelectingRecentVaultSwitchesToLocalMode(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.lifecycleMode = "remote" u.requestMasterPassFocus = false u.recentVaults = []string{"/tmp/example.kdbx"} u.recentVaultClicks = make([]widget.Clickable, 1) u.recentVaultClicks[0].Click() gtx := layout.Context{} for u.recentVaultClicks[0].Clicked(gtx) { if 0 < len(u.recentVaults) { u.lifecycleMode = "local" u.vaultPath.SetText(u.recentVaults[0]) u.requestMasterPassFocus = true } } if got := u.lifecycleMode; got != "local" { t.Fatalf("lifecycleMode after recent vault click = %q, want local", got) } if got := u.vaultPath.Text(); got != "/tmp/example.kdbx" { t.Fatalf("vaultPath after recent vault click = %q, want /tmp/example.kdbx", got) } if !u.requestMasterPassFocus { t.Fatal("requestMasterPassFocus after recent vault click = false, want true") } } func TestRestoreStartupLifecycleTargetSelectsMostRecentLocalVault(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.lifecycleMode = "remote" u.vaultPath.SetText("") u.recentVaults = []string{"/tmp/example.kdbx"} u.recentVaultUsedAt["/tmp/example.kdbx"] = time.Date(2026, time.April, 5, 1, 2, 3, 0, time.UTC) u.recentRemotes = nil u.restoreStartupLifecycleTarget() if got := u.lifecycleMode; got != "local" { t.Fatalf("lifecycleMode after restore = %q, want local", got) } if got := u.vaultPath.Text(); got != "/tmp/example.kdbx" { t.Fatalf("vaultPath after restore = %q, want /tmp/example.kdbx", got) } } func TestRestoreStartupLifecycleTargetUsesLocalCacheFromRecentRemote(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.lifecycleMode = "remote" u.vaultPath.SetText("") u.recentVaults = []string{"/tmp/older.kdbx"} u.recentVaultUsedAt["/tmp/older.kdbx"] = time.Date(2026, time.April, 5, 1, 2, 3, 0, time.UTC) u.recentRemotes = []recentRemoteRecord{{ BaseURL: "https://dav.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", LocalVaultPath: "/tmp/bellagio-cache.kdbx", UsedAt: time.Date(2026, time.April, 5, 2, 2, 3, 0, time.UTC).Format(time.RFC3339Nano), }} u.restoreStartupLifecycleTarget() if got := u.lifecycleMode; got != "local" { t.Fatalf("lifecycleMode after restore = %q, want local", got) } if got := u.vaultPath.Text(); got != "/tmp/bellagio-cache.kdbx" { t.Fatalf("vaultPath after restore = %q, want /tmp/bellagio-cache.kdbx", got) } } func TestShowLocalVaultChooser(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.lifecycleMode = "local" u.vaultPath.SetText("") if got := u.showLocalVaultChooser(); !got { t.Fatal("showLocalVaultChooser() = false, want true when no local vault is selected") } u.vaultPath.SetText("/tmp/example.kdbx") if got := u.showLocalVaultChooser(); got { t.Fatal("showLocalVaultChooser() = true, want false when a local vault is selected") } u.lifecycleMode = "remote" if got := u.showLocalVaultChooser(); !got { t.Fatal("showLocalVaultChooser() = false, want true outside local lifecycle mode") } } func TestShowRemoteConnectionChooser(t *testing.T) { t.Parallel() dir := t.TempDir() u := newUIWithState("desktop", &session.Manager{}, statePaths{ DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), }) u.lifecycleMode = "remote" u.remoteBaseURL.SetText("") u.remotePath.SetText("") if got := u.showRemoteConnectionChooser(); !got { t.Fatal("showRemoteConnectionChooser() = false, want true when no remote connection is selected") } u.remoteBaseURL.SetText("https://dav.crew.example.invalid") u.remotePath.SetText("vaults/bellagio.kdbx") if got := u.showRemoteConnectionChooser(); !got { t.Fatal("showRemoteConnectionChooser() = false, want true while manually entering a remote connection") } u.applyRecentRemoteRecord(recentRemoteRecord{ BaseURL: "https://dav.crew.example.invalid", Path: "vaults/bellagio.kdbx", }) if got := u.showRemoteConnectionChooser(); got { t.Fatal("showRemoteConnectionChooser() = true, want false after selecting a saved remote connection") } u.lifecycleMode = "local" if got := u.showRemoteConnectionChooser(); !got { t.Fatal("showRemoteConnectionChooser() = false, want true outside remote lifecycle mode") } } func TestApplyingRecentRemoteRecordMarksSelectedRemoteConnection(t *testing.T) { t.Parallel() dir := t.TempDir() u := newUIWithState("desktop", &session.Manager{}, statePaths{ DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), }) if u.hasSelectedRemoteTarget() { t.Fatal("hasSelectedRemoteTarget() = true, want false before selecting a saved remote connection") } u.applyRecentRemoteRecord(recentRemoteRecord{ BaseURL: "https://dav.crew.example.invalid", Path: "vaults/bellagio.kdbx", }) if !u.hasSelectedRemoteTarget() { t.Fatal("hasSelectedRemoteTarget() = false, want true after selecting a saved remote connection") } } func TestUIAvailableRemoteProfilesUsesVaultProfiles(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ RemoteProfiles: []vault.RemoteProfile{ { ID: "bellagio-webdav", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", }, { ID: "archive-webdav", Name: "Archive Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", Path: "files/bellagio/archive.kdbx", }, }, }) got := u.availableRemoteProfiles() if len(got) != 2 { t.Fatalf("len(availableRemoteProfiles()) = %d, want 2", len(got)) } if got[0].ID != "archive-webdav" || got[1].ID != "bellagio-webdav" { t.Fatalf("availableRemoteProfiles() = %#v, want profiles sorted by name/id", got) } } func TestUIAvailableRemoteCredentialEntriesUsesVaultEntries(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{ {ID: "cred-2", Title: "Zulu Sign-In", Username: "zuser", Path: []string{"Crew", "Internet"}}, {ID: "cred-1", Title: "Alpha Sign-In", Username: "auser", Path: []string{"Crew", "Internet"}}, }, }) got := u.availableRemoteCredentialEntries() if len(got) != 2 { t.Fatalf("len(availableRemoteCredentialEntries()) = %d, want 2", len(got)) } if got[0].ID != "cred-1" || got[1].ID != "cred-2" { t.Fatalf("availableRemoteCredentialEntries() = %#v, want entries sorted by title", got) } } func TestUIAvailableRemoteProfilesReturnsEmptyWhenLocked(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", summarySession{locked: true}) if got := u.availableRemoteProfiles(); len(got) != 0 { t.Fatalf("availableRemoteProfiles() = %#v, want empty when locked", got) } } func TestUISelectVaultRemoteProfileUpdatesSelectionAndTargetFields(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ RemoteProfiles: []vault.RemoteProfile{{ ID: "bellagio-webdav", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", }}, }) u.selectVaultRemoteProfile("bellagio-webdav") if got := u.selectedVaultRemoteProfileID; got != "bellagio-webdav" { t.Fatalf("selectedVaultRemoteProfileID = %q, want bellagio-webdav", got) } if got := u.remoteBaseURL.Text(); got != "https://dav.example.invalid/remote.php/dav" { t.Fatalf("remoteBaseURL = %q, want resolved profile base URL", got) } if got := u.remotePath.Text(); got != "files/bellagio/keepass.kdbx" { t.Fatalf("remotePath = %q, want resolved profile path", got) } } func TestUISelectVaultRemoteCredentialEntryUpdatesSelection(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{{ ID: "remote-creds-1", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", Path: []string{"Crew", "Internet"}, }}, }) u.selectVaultRemoteCredentialEntry("remote-creds-1") if got := u.selectedVaultRemoteCredentialEntryID; got != "remote-creds-1" { t.Fatalf("selectedVaultRemoteCredentialEntryID = %q, want remote-creds-1", got) } } func TestUIShouldShowSavedRemoteBindingSelectorsWhenMultipleChoices(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{ {ID: "remote-creds-1", Title: "Alpha Sign-In", Username: "auser", Path: []string{"Crew", "Internet"}}, {ID: "remote-creds-2", Title: "Bravo Sign-In", Username: "frankcatton", Path: []string{"Crew", "Internet"}}, }, RemoteProfiles: []vault.RemoteProfile{ {ID: "profile-1", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav1.example.invalid", Path: "files/bellagio.kdbx"}, {ID: "profile-2", Name: "Vault Console", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav2.example.invalid", Path: "files/console.kdbx"}, }, }) if !u.shouldShowSavedRemoteBindingSelectors() { t.Fatal("shouldShowSavedRemoteBindingSelectors() = false, want true with multiple profiles and credentials") } } func TestUIShouldHideSavedRemoteBindingSelectorsForSingleChoice(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{{ ID: "remote-creds-1", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ ID: "bellagio-webdav", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", }}, }) if u.shouldShowSavedRemoteBindingSelectors() { t.Fatal("shouldShowSavedRemoteBindingSelectors() = true, want false with a single saved binding choice") } } func TestUISavedRemoteBindingSummaryUsesImplicitSingleChoice(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{{ ID: "remote-creds-1", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ ID: "bellagio-webdav", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", }}, }) profileLabel, credentialLabel, syncLabel, ok := u.savedRemoteBindingSummary() if !ok { t.Fatal("savedRemoteBindingSummary() ok = false, want true") } if profileLabel != "Bellagio Vault" { t.Fatalf("profileLabel = %q, want Bellagio Vault", profileLabel) } if credentialLabel != "Bellagio WebDAV Sign-In · linuscaldwell" { t.Fatalf("credentialLabel = %q, want Bellagio WebDAV Sign-In · linuscaldwell", credentialLabel) } if syncLabel != "Sync manually when you choose Use Remote Sync." { t.Fatalf("syncLabel = %q, want manual sync summary", syncLabel) } } func TestUISavedRemoteBindingSummaryMentionsAutomaticSyncMode(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{{ ID: "remote-creds-1", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ ID: "bellagio-webdav", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", }}, }) u.selectedVaultRemoteSyncMode = appstate.SyncModeAutomaticOnOpenSave _, _, syncLabel, ok := u.savedRemoteBindingSummary() if !ok { t.Fatal("savedRemoteBindingSummary() ok = false, want true") } if syncLabel != "Syncs automatically on open and save." { t.Fatalf("syncLabel = %q, want automatic sync summary", syncLabel) } } func TestUISavedRemoteBindingHeadingUsesSyncLanguageForSingleChoice(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{{ ID: "remote-creds-1", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ ID: "bellagio-webdav", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", }}, }) if got := u.savedRemoteBindingHeading(); got != "Use this vault's saved remote sync target" { t.Fatalf("savedRemoteBindingHeading() = %q, want sync-target guidance", got) } } func TestUIOpenSelectedVaultRemoteButtonLabelUsesSyncLanguageForSingleChoice(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{{ ID: "remote-creds-1", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ ID: "bellagio-webdav", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", }}, }) if got := u.openSelectedVaultRemoteButtonLabel(); got != "Use Remote Sync" { t.Fatalf("openSelectedVaultRemoteButtonLabel() = %q, want Use Remote Sync", got) } } func TestUIOpenSelectedVaultRemoteButtonLabelUsesSavedRemoteLanguageForMultipleChoices(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{ {ID: "remote-creds-1", Title: "Alpha Sign-In", Username: "auser", Path: []string{"Crew", "Internet"}}, {ID: "remote-creds-2", Title: "Bravo Sign-In", Username: "frankcatton", Path: []string{"Crew", "Internet"}}, }, RemoteProfiles: []vault.RemoteProfile{ {ID: "profile-1", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav1.example.invalid", Path: "files/bellagio.kdbx"}, {ID: "profile-2", Name: "Vault Console", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav2.example.invalid", Path: "files/console.kdbx"}, }, }) if got := u.openSelectedVaultRemoteButtonLabel(); got != "Open Saved Remote" { t.Fatalf("openSelectedVaultRemoteButtonLabel() = %q, want Open Saved Remote", got) } } func TestUIShouldShowDirectRemoteSyncShortcutForSavedBinding(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{{ ID: "remote-creds-1", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ ID: "bellagio-webdav", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", }}, }) u.state.Section = appstate.SectionEntries if !u.shouldShowDirectRemoteSyncShortcut() { t.Fatal("shouldShowDirectRemoteSyncShortcut() = false, want true for an opened vault with a saved remote binding") } } func TestUIRemoteSyncShortcutsHaveParityAcrossModes(t *testing.T) { t.Parallel() for _, mode := range []string{"desktop", "phone"} { mode := mode t.Run(mode, func(t *testing.T) { t.Parallel() u := newUIWithModel(mode, vault.Model{ Entries: []vault.Entry{{ ID: "remote-creds-1", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ ID: "bellagio-webdav", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", }}, }) u.state.Section = appstate.SectionEntries if !u.shouldShowDirectRemoteSyncShortcut() { t.Fatal("shouldShowDirectRemoteSyncShortcut() = false, want true") } if !u.shouldShowRemoteSyncSettingsShortcut() { t.Fatal("shouldShowRemoteSyncSettingsShortcut() = false, want true") } if !u.shouldShowRemoveRemoteSyncShortcut() { t.Fatal("shouldShowRemoveRemoteSyncShortcut() = false, want true") } if u.shouldShowRemoteSyncSetupShortcut() { t.Fatal("shouldShowRemoteSyncSetupShortcut() = true, want false when a binding exists") } if got := u.directRemoteSyncShortcutLabel(); got != "Use Remote Sync" { t.Fatalf("directRemoteSyncShortcutLabel() = %q, want Use Remote Sync", got) } if got := u.remoteSyncSettingsShortcutLabel(); got != "Remote Sync Settings" { t.Fatalf("remoteSyncSettingsShortcutLabel() = %q, want Remote Sync Settings", got) } if got := u.removeRemoteSyncShortcutLabel(); got != "Stop Using Remote Sync" { t.Fatalf("removeRemoteSyncShortcutLabel() = %q, want Stop Using Remote Sync", got) } }) } } func TestUIShouldHideDirectRemoteSyncShortcutWithoutSavedBinding(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{}) u.state.Section = appstate.SectionEntries if u.shouldShowDirectRemoteSyncShortcut() { t.Fatal("shouldShowDirectRemoteSyncShortcut() = true, want false without a saved remote binding") } } func TestUIDirectRemoteSyncShortcutLabelUsesSyncLanguage(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) if got := u.directRemoteSyncShortcutLabel(); got != "Use Remote Sync" { t.Fatalf("directRemoteSyncShortcutLabel() = %q, want Use Remote Sync", got) } } func TestUIShouldShowRemoteSyncSetupShortcutForOpenedLocalVaultWithoutSavedBinding(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{{ ID: "entry-1", Title: "Vault Console", Path: []string{"Crew", "Internet"}, }}, }) u.state.Section = appstate.SectionEntries if !u.shouldShowRemoteSyncSetupShortcut() { t.Fatal("shouldShowRemoteSyncSetupShortcut() = false, want true for opened local vault without saved binding") } } func TestUIRemoteSetupShortcutHasParityAcrossModes(t *testing.T) { t.Parallel() for _, mode := range []string{"desktop", "phone"} { 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", Path: []string{"Crew", "Internet"}, }}, }) u.state.Section = appstate.SectionEntries if !u.shouldShowRemoteSyncSetupShortcut() { t.Fatal("shouldShowRemoteSyncSetupShortcut() = false, want true") } if u.shouldShowDirectRemoteSyncShortcut() { t.Fatal("shouldShowDirectRemoteSyncShortcut() = true, want false without a binding") } if u.shouldShowRemoteSyncSettingsShortcut() { t.Fatal("shouldShowRemoteSyncSettingsShortcut() = true, want false without a binding") } if u.shouldShowRemoveRemoteSyncShortcut() { t.Fatal("shouldShowRemoveRemoteSyncShortcut() = true, want false without a binding") } if got := u.remoteSyncSetupShortcutLabel(); got != "Set Up Remote Sync" { t.Fatalf("remoteSyncSetupShortcutLabel() = %q, want Set Up Remote Sync", got) } }) } } func TestUISyncMenuActionLabelsIncludeRemoteSetupForUnboundVault(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{{ ID: "entry-1", Title: "Mint Console", Path: []string{"Crew", "Signals"}, }}, }) u.state.Section = appstate.SectionEntries got := u.syncMenuActionLabels() if !slices.Contains(got, "Set Up Remote Sync") { t.Fatalf("syncMenuActionLabels() = %v, want Set Up Remote Sync", got) } if slices.Contains(got, "Use Remote Sync") { t.Fatalf("syncMenuActionLabels() = %v, want no Use Remote Sync without saved binding", got) } } func TestUISyncMenuActionLabelsIncludeSavedRemoteActionsForBoundVault(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{{ ID: "remote-creds-1", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", Path: []string{"Crew", "Signals"}, }}, RemoteProfiles: []vault.RemoteProfile{{ ID: "bellagio-webdav", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", }}, }) u.state.Section = appstate.SectionEntries got := u.syncMenuActionLabels() for _, want := range []string{"Use Remote Sync", "Remote Sync Settings", "Stop Using Remote Sync"} { if !slices.Contains(got, want) { t.Fatalf("syncMenuActionLabels() = %v, want %q", got, want) } } } func TestUIShouldHideRemoteSyncSetupShortcutWhenSavedBindingExists(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{{ ID: "remote-creds-1", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ ID: "bellagio-webdav", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", }}, }) u.state.Section = appstate.SectionEntries if u.shouldShowRemoteSyncSetupShortcut() { t.Fatal("shouldShowRemoteSyncSetupShortcut() = true, want false when saved binding already exists") } } func TestUIRemoteSyncSetupShortcutLabelUsesClearLanguage(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) if got := u.remoteSyncSetupShortcutLabel(); got != "Set Up Remote Sync" { t.Fatalf("remoteSyncSetupShortcutLabel() = %q, want Set Up Remote Sync", got) } } func TestUILifecycleRemoteSyncActionLabelUsesSetupLanguageWithoutSavedBinding(t *testing.T) { t.Parallel() dir := t.TempDir() u := newUIWithSession("desktop", &session.Manager{}, statePaths{ DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), }) u.vaultPath.SetText("/vaults/bellagio.kdbx") if !u.shouldShowLifecycleRemoteSyncAction() { t.Fatal("shouldShowLifecycleRemoteSyncAction() = false, want true with a selected vault") } if got := u.lifecycleRemoteSyncActionLabel(); got != "Open Vault And Set Up Remote Sync" { t.Fatalf("lifecycleRemoteSyncActionLabel() = %q, want setup label", got) } } func TestUILifecycleRemoteSyncActionLabelUsesSettingsLanguageWithSavedBinding(t *testing.T) { t.Parallel() dir := t.TempDir() u := newUIWithSession("desktop", &session.Manager{}, statePaths{ DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), }) u.vaultPath.SetText("/vaults/bellagio.kdbx") u.recentRemotes = []recentRemoteRecord{{ BaseURL: "https://dav.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", LocalVaultPath: "/vaults/bellagio.kdbx", RemoteProfileID: "bellagio-webdav", CredentialEntryID: "remote-creds-1", SyncMode: string(appstate.SyncModeManual), }} if got := u.lifecycleRemoteSyncActionLabel(); got != "Open Vault And Open Remote Sync Settings" { t.Fatalf("lifecycleRemoteSyncActionLabel() = %q, want settings label", got) } } func TestUIShouldShowRemoteSyncSettingsShortcutForSavedBinding(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{{ ID: "remote-creds-1", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ ID: "bellagio-webdav", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", }}, }) u.state.Section = appstate.SectionEntries if !u.shouldShowRemoteSyncSettingsShortcut() { t.Fatal("shouldShowRemoteSyncSettingsShortcut() = false, want true when a saved binding exists") } } func TestUIRemoteSyncSettingsShortcutLabelUsesClearLanguage(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) if got := u.remoteSyncSettingsShortcutLabel(); got != "Remote Sync Settings" { t.Fatalf("remoteSyncSettingsShortcutLabel() = %q, want Remote Sync Settings", got) } } func TestUIShouldShowRemoveRemoteSyncShortcutForSavedBinding(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{{ ID: "remote-creds-1", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ ID: "bellagio-webdav", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", }}, }) u.state.Section = appstate.SectionEntries if !u.shouldShowRemoveRemoteSyncShortcut() { t.Fatal("shouldShowRemoveRemoteSyncShortcut() = false, want true when a saved binding exists") } } func TestUIRemoveRemoteSyncShortcutLabelUsesClearLanguage(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) if got := u.removeRemoteSyncShortcutLabel(); got != "Stop Using Remote Sync" { t.Fatalf("removeRemoteSyncShortcutLabel() = %q, want Stop Using Remote Sync", got) } } func TestUIOpenRemoteSyncSetupDialogPrefillsCurrentVaultSetupFlow(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{{ ID: "remote-creds-1", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", Password: "bellagio-pass-1", Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ ID: "bellagio-webdav", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", }}, }) u.vaultPath.SetText("/vaults/bellagio.kdbx") u.openRemoteSyncSetupDialog() if !u.syncDialogOpen { t.Fatal("syncDialogOpen = false, want true") } if got := u.syncDialogPurpose; got != syncDialogPurposeRemoteSetup { t.Fatalf("syncDialogPurpose = %q, want remote setup", got) } if got := u.syncSourceMode; got != syncSourceRemote { t.Fatalf("syncSourceMode = %q, want remote", got) } if got := u.syncDirection; got != syncDirectionPush { t.Fatalf("syncDirection = %q, want push", got) } if got := u.syncLocalPath.Text(); got != "/vaults/bellagio.kdbx" { t.Fatalf("syncLocalPath = %q, want current vault path", got) } if got := u.syncRemoteBaseURL.Text(); got != "https://dav.example.invalid/remote.php/dav" { t.Fatalf("syncRemoteBaseURL = %q, want saved remote base URL", got) } if got := u.syncRemotePath.Text(); got != "files/bellagio/keepass.kdbx" { t.Fatalf("syncRemotePath = %q, want saved remote path", got) } if got := u.syncRemoteUsername.Text(); got != "linuscaldwell" { t.Fatalf("syncRemoteUsername = %q, want linuscaldwell", got) } if got := u.syncRemotePassword.Text(); got != "bellagio-pass-1" { t.Fatalf("syncRemotePassword = %q, want bellagio-pass-1", got) } } func TestUILifecycleRemoteSyncActionOpensSetupAfterVaultOpen(t *testing.T) { t.Parallel() key := vault.MasterKey{Password: "correct horse battery staple"} path := filepath.Join(t.TempDir(), "bellagio.kdbx") writeKDBXMainTestFile(t, path, vault.Model{ Entries: []vault.Entry{{ ID: "vault-console", Title: "Vault Console", Username: "dannyocean", Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }}, }, key) u := newUIWithSession("desktop", &session.Manager{}) u.masterPassword.SetText(key.Password) u.vaultPath.SetText(path) u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSetup if err := u.openVaultAction(); err != nil { t.Fatalf("openVaultAction() error = %v", err) } if !u.syncDialogOpen { t.Fatal("syncDialogOpen = false, want remote sync setup dialog") } if got := u.syncDialogTitle(); got != "Set Up Remote Sync" { t.Fatalf("syncDialogTitle() = %q, want Set Up Remote Sync", got) } } func TestUILifecycleRemoteSyncActionOpensSettingsAfterVaultOpen(t *testing.T) { t.Parallel() key := vault.MasterKey{Password: "correct horse battery staple"} path := filepath.Join(t.TempDir(), "bellagio.kdbx") writeKDBXMainTestFile(t, path, vault.Model{ Entries: []vault.Entry{ { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }, { ID: "remote-creds-1", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", Password: "bellagio-pass-1", URL: "https://dav.example.invalid/remote.php/dav", Path: []string{"Crew", "Internet"}, }, }, RemoteProfiles: []vault.RemoteProfile{{ ID: "bellagio-webdav", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", }}, }, key) dir := t.TempDir() u := newUIWithSession("desktop", &session.Manager{}, statePaths{ DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), }) u.masterPassword.SetText(key.Password) u.vaultPath.SetText(path) u.recentRemotes = []recentRemoteRecord{{ BaseURL: "https://dav.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", LocalVaultPath: path, RemoteProfileID: "bellagio-webdav", CredentialEntryID: "remote-creds-1", SyncMode: string(appstate.SyncModeManual), }} u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSettings if err := u.openVaultAction(); err != nil { t.Fatalf("openVaultAction() error = %v", err) } if !u.syncDialogOpen { t.Fatal("syncDialogOpen = false, want remote sync settings dialog") } if got := u.syncDialogTitle(); got != "Remote Sync Settings" { t.Fatalf("syncDialogTitle() = %q, want Remote Sync Settings", got) } } func TestUISelectedLocalVaultRemoteSyncSummaryMentionsSetup(t *testing.T) { t.Parallel() dir := t.TempDir() u := newUIWithSession("desktop", &session.Manager{}, statePaths{ DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), }) if got := u.selectedLocalVaultRemoteSyncSummary("/vaults/bellagio.kdbx"); got != "Open this vault to set up a WebDAV sync target for it." { t.Fatalf("selectedLocalVaultRemoteSyncSummary() = %q, want setup guidance", got) } } func TestUISelectedLocalVaultRemoteSyncSummaryMentionsAutomaticSync(t *testing.T) { t.Parallel() dir := t.TempDir() u := newUIWithSession("desktop", &session.Manager{}, statePaths{ DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), }) u.recentRemotes = []recentRemoteRecord{{ BaseURL: "https://dav.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", LocalVaultPath: "/vaults/bellagio.kdbx", RemoteProfileID: "bellagio-webdav", CredentialEntryID: "remote-creds-1", SyncMode: string(appstate.SyncModeAutomaticOnOpenSave), }} if got := u.selectedLocalVaultRemoteSyncSummary("/vaults/bellagio.kdbx"); got != "Saved remote sync target: keepass.kdbx · dav.example.invalid · Syncs automatically on open and save." { t.Fatalf("selectedLocalVaultRemoteSyncSummary() = %q, want automatic sync guidance", got) } } func TestUISelectedLocalVaultRemoteSyncSummaryMentionsManualSync(t *testing.T) { t.Parallel() dir := t.TempDir() u := newUIWithSession("desktop", &session.Manager{}, statePaths{ DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), }) u.recentRemotes = []recentRemoteRecord{{ BaseURL: "https://dav.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", LocalVaultPath: "/vaults/bellagio.kdbx", RemoteProfileID: "bellagio-webdav", CredentialEntryID: "remote-creds-1", SyncMode: string(appstate.SyncModeManual), }} if got := u.selectedLocalVaultRemoteSyncSummary("/vaults/bellagio.kdbx"); got != "Saved remote sync target: keepass.kdbx · dav.example.invalid · Sync manually when you choose Use Remote Sync." { t.Fatalf("selectedLocalVaultRemoteSyncSummary() = %q, want manual sync guidance", got) } } func TestUISyncDialogUsesRemoteSetupCopy(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.syncDialogPurpose = syncDialogPurposeRemoteSetup u.syncSetupAutomatic.Value = true if got := u.syncDialogTitle(); got != "Set Up Remote Sync" { t.Fatalf("syncDialogTitle() = %q, want Set Up Remote Sync", got) } if got := u.syncDialogDescription(); got != "Send this local vault to a WebDAV target, then use that target for future sync." { t.Fatalf("syncDialogDescription() = %q, want remote setup guidance", got) } if got := u.syncDialogConfirmButtonLabel(); got != "Set Up Remote Sync" { t.Fatalf("syncDialogConfirmButtonLabel() = %q, want Set Up Remote Sync", got) } if u.shouldShowSyncDirectionChoices() { t.Fatal("shouldShowSyncDirectionChoices() = true, want false for remote setup") } if u.shouldShowSyncSourceChoices() { t.Fatal("shouldShowSyncSourceChoices() = true, want false for remote setup") } if got := syncDialogSummaryText(syncDialogPurposeRemoteSetup, syncSourceRemote, syncDirectionPush); got != "Push this local vault to a WebDAV target and save that target for future sync." { t.Fatalf("syncDialogSummaryText(remote setup) = %q, want setup-specific summary", got) } if got := u.syncSetupMode(); got != appstate.SyncModeAutomaticOnOpenSave { t.Fatalf("syncSetupMode() = %q, want automatic_on_open_save", got) } } func TestUISyncDialogUsesRemoteSettingsCopyForExistingBinding(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{{ ID: "remote-creds-1", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", Password: "bellagio-pass-1", Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ ID: "bellagio-webdav", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", }}, }) u.state.Section = appstate.SectionEntries u.vaultPath.SetText("/vaults/bellagio.kdbx") u.recentRemotes = []recentRemoteRecord{{ BaseURL: "https://dav.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", LocalVaultPath: "/vaults/bellagio.kdbx", RemoteProfileID: "bellagio-webdav", CredentialEntryID: "remote-creds-1", SyncMode: string(appstate.SyncModeManual), }} u.openRemoteSyncSetupDialog() if got := u.syncDialogTitle(); got != "Remote Sync Settings" { t.Fatalf("syncDialogTitle() = %q, want Remote Sync Settings", got) } if got := u.syncDialogDescription(); got != "Review or change this vault's saved WebDAV target, credentials, and sync mode." { t.Fatalf("syncDialogDescription() = %q, want settings guidance", got) } if got := u.syncDialogConfirmButtonLabel(); got != "Save Remote Sync Settings" { t.Fatalf("syncDialogConfirmButtonLabel() = %q, want Save Remote Sync Settings", got) } if u.syncSetupAutomatic.Value { t.Fatal("syncSetupAutomatic.Value = true, want false for an existing manual binding") } } func TestUISyncDialogUsesAdvancedCopy(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.syncDialogPurpose = syncDialogPurposeAdvanced if got := u.syncDialogTitle(); got != "Advanced Sync" { t.Fatalf("syncDialogTitle() = %q, want Advanced Sync", got) } if got := u.syncDialogConfirmButtonLabel(); got != "Synchronize" { t.Fatalf("syncDialogConfirmButtonLabel() = %q, want Synchronize", got) } if !u.shouldShowSyncDirectionChoices() { t.Fatal("shouldShowSyncDirectionChoices() = false, want true for advanced sync") } if !u.shouldShowSyncSourceChoices() { t.Fatal("shouldShowSyncSourceChoices() = false, want true for advanced sync") } if got := syncDialogSummaryText(syncDialogPurposeAdvanced, syncSourceRemote, syncDirectionPush); got != "Push the current vault into another WebDAV-backed vault." { t.Fatalf("syncDialogSummaryText(advanced) = %q, want advanced summary", got) } } func TestUIRemoteSyncSetupPersistsBindingAfterSuccessfulPush(t *testing.T) { t.Parallel() key := vault.MasterKey{Password: "correct horse battery staple"} currentPath := filepath.Join(t.TempDir(), "current.kdbx") writeKDBXMainTestFile(t, currentPath, vault.Model{ Entries: []vault.Entry{{ ID: "entry-current", Title: "Vault Console", Username: "dannyocean", Password: "token-current", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }}, }, key) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: w.WriteHeader(http.StatusNotFound) case http.MethodPut: w.Header().Set("ETag", "\"v1\"") w.WriteHeader(http.StatusNoContent) default: t.Fatalf("unexpected method %s", r.Method) } })) defer server.Close() dir := t.TempDir() u := newUIWithSession("desktop", &session.Manager{}, statePaths{ DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), }) u.masterPassword.SetText(key.Password) u.vaultPath.SetText(currentPath) if err := u.openVaultAction(); err != nil { t.Fatalf("openVaultAction() error = %v", err) } u.openRemoteSyncSetupDialog() u.syncRemoteBaseURL.SetText(server.URL) u.syncRemotePath.SetText("vaults/other.kdbx") u.syncRemoteUsername.SetText("linuscaldwell") u.syncRemotePassword.SetText("bellagio-pass-1") if err := u.advancedSyncAction(); err != nil { t.Fatalf("advancedSyncAction() error = %v", err) } if got := u.selectedVaultRemoteProfileID; got == "" { t.Fatal("selectedVaultRemoteProfileID = empty, want saved binding") } if got := u.selectedVaultRemoteCredentialEntryID; got == "" { t.Fatal("selectedVaultRemoteCredentialEntryID = empty, want saved credential binding") } if got := u.remoteBaseURL.Text(); got != server.URL { t.Fatalf("remoteBaseURL = %q, want %q", got, server.URL) } if got := u.remotePath.Text(); got != "vaults/other.kdbx" { t.Fatalf("remotePath = %q, want vaults/other.kdbx", got) } if got := len(u.recentRemotes); got != 1 { t.Fatalf("len(recentRemotes) = %d, want 1", got) } if got := u.recentRemotes[0].LocalVaultPath; got != currentPath { t.Fatalf("recentRemotes[0].LocalVaultPath = %q, want %q", got, currentPath) } if got := u.selectedVaultRemoteSyncMode; got != appstate.SyncModeAutomaticOnOpenSave { t.Fatalf("selectedVaultRemoteSyncMode = %q, want automatic_on_open_save", got) } if got := u.state.StatusMessage; got != "Remote sync is set up for this vault." { t.Fatalf("StatusMessage = %q, want setup success message", got) } if u.shouldShowRemoteSyncSetupShortcut() { t.Fatal("shouldShowRemoteSyncSetupShortcut() = true after setup, want false") } if !u.shouldShowDirectRemoteSyncShortcut() { t.Fatal("shouldShowDirectRemoteSyncShortcut() = false after setup, want true") } var reopened session.Manager if err := reopened.Open(currentPath, key); err != nil { t.Fatalf("reopened.Open(currentPath) error = %v", err) } reopenedModel, err := reopened.Current() if err != nil { t.Fatalf("reopened.Current() error = %v", err) } profiles := reopenedModel.RemoteProfiles if len(profiles) != 1 { t.Fatalf("len(reopened.RemoteProfiles) = %d, want 1 persisted profile", len(profiles)) } if profiles[0].BaseURL != server.URL || profiles[0].Path != "vaults/other.kdbx" { t.Fatalf("reopened.RemoteProfiles[0] = %#v, want persisted setup target", profiles[0]) } cred, err := reopenedModel.EntryByID(u.selectedVaultRemoteCredentialEntryID) if err != nil { t.Fatalf("reopened.EntryByID(saved credential) error = %v", err) } if cred.Username != "linuscaldwell" || cred.Password != "bellagio-pass-1" { t.Fatalf("reopened saved credential = %#v, want linuscaldwell/bellagio-pass-1", cred) } } func TestUIRemoteSyncSetupCanPersistManualSyncMode(t *testing.T) { t.Parallel() key := vault.MasterKey{Password: "correct horse battery staple"} currentPath := filepath.Join(t.TempDir(), "current.kdbx") writeKDBXMainTestFile(t, currentPath, vault.Model{ Entries: []vault.Entry{{ID: "entry-current", Title: "Vault Console", Path: []string{"Root", "Internet"}}}, }, key) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: w.WriteHeader(http.StatusNotFound) case http.MethodPut: w.Header().Set("ETag", "\"v1\"") w.WriteHeader(http.StatusNoContent) default: t.Fatalf("unexpected method %s", r.Method) } })) defer server.Close() dir := t.TempDir() u := newUIWithSession("desktop", &session.Manager{}, statePaths{ DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), }) u.masterPassword.SetText(key.Password) u.vaultPath.SetText(currentPath) if err := u.openVaultAction(); err != nil { t.Fatalf("openVaultAction() error = %v", err) } u.openRemoteSyncSetupDialog() u.syncSetupAutomatic.Value = false u.syncRemoteBaseURL.SetText(server.URL) u.syncRemotePath.SetText("vaults/manual.kdbx") u.syncRemoteUsername.SetText("linuscaldwell") u.syncRemotePassword.SetText("bellagio-pass-1") if err := u.advancedSyncAction(); err != nil { t.Fatalf("advancedSyncAction() error = %v", err) } if got := u.selectedVaultRemoteSyncMode; got != appstate.SyncModeManual { t.Fatalf("selectedVaultRemoteSyncMode = %q, want manual", got) } if got := u.recentRemotes[0].SyncMode; got != string(appstate.SyncModeManual) { t.Fatalf("recentRemotes[0].SyncMode = %q, want manual", got) } } func TestUIRemoveSelectedRemoteBindingActionClearsVaultBindingAndRecentRefs(t *testing.T) { t.Parallel() key := vault.MasterKey{Password: "correct horse battery staple"} currentPath := filepath.Join(t.TempDir(), "current.kdbx") writeKDBXMainTestFile(t, currentPath, vault.Model{ Entries: []vault.Entry{{ ID: "remote-creds-1", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", Password: "bellagio-pass-1", Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ ID: "bellagio-webdav", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", }}, }, key) dir := t.TempDir() u := newUIWithSession("desktop", &session.Manager{}, statePaths{ DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), }) u.masterPassword.SetText(key.Password) u.vaultPath.SetText(currentPath) u.recentRemotes = []recentRemoteRecord{{ BaseURL: "https://dav.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", LocalVaultPath: currentPath, RemoteProfileID: "bellagio-webdav", CredentialEntryID: "remote-creds-1", SyncMode: string(appstate.SyncModeAutomaticOnOpenSave), }} if err := u.openVaultAction(); err != nil { t.Fatalf("openVaultAction() error = %v", err) } if err := u.removeSelectedRemoteBindingAction(); err != nil { t.Fatalf("removeSelectedRemoteBindingAction() error = %v", err) } if got := u.selectedVaultRemoteProfileID; got != "" { t.Fatalf("selectedVaultRemoteProfileID = %q, want empty", got) } if got := u.selectedVaultRemoteCredentialEntryID; got != "" { t.Fatalf("selectedVaultRemoteCredentialEntryID = %q, want empty", got) } if got := u.selectedVaultRemoteSyncMode; got != appstate.SyncModeManual { t.Fatalf("selectedVaultRemoteSyncMode = %q, want manual", got) } if got := u.recentRemotes[0].RemoteProfileID; got != "" { t.Fatalf("recentRemotes[0].RemoteProfileID = %q, want empty", got) } if got := u.recentRemotes[0].CredentialEntryID; got != "" { t.Fatalf("recentRemotes[0].CredentialEntryID = %q, want empty", got) } if got := u.recentRemotes[0].SyncMode; got != "" { t.Fatalf("recentRemotes[0].SyncMode = %q, want empty", got) } if got := u.state.StatusMessage; got != "Remote sync is no longer set up for this vault." { t.Fatalf("StatusMessage = %q, want removal status", got) } if u.shouldShowDirectRemoteSyncShortcut() { t.Fatal("shouldShowDirectRemoteSyncShortcut() = true, want false after removing binding") } if !u.shouldShowRemoteSyncSetupShortcut() { t.Fatal("shouldShowRemoteSyncSetupShortcut() = false, want true after removing binding") } reopened := newUIWithSession("desktop", &session.Manager{}) reopened.masterPassword.SetText(key.Password) reopened.vaultPath.SetText(currentPath) if err := reopened.openVaultAction(); err != nil { t.Fatalf("reopened.openVaultAction() error = %v", err) } if got := len(reopened.availableRemoteProfiles()); got != 0 { t.Fatalf("len(reopened.availableRemoteProfiles()) = %d, want 0", got) } if got := len(reopened.availableRemoteCredentialEntries()); got != 0 { t.Fatalf("len(reopened.availableRemoteCredentialEntries()) = %d, want 0", got) } } func TestUISaveCurrentRemoteBindingActionPersistsBindingIntoVault(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{}) u.currentPath = []string{"Crew", "Internet"} u.vaultPath.SetText("/tmp/bellagio.kdbx") u.remoteBaseURL.SetText("https://dav.example.invalid/remote.php/dav") u.remotePath.SetText("files/bellagio/keepass.kdbx") u.remoteUsername.SetText("linuscaldwell") u.remotePassword.SetText("bellagio-pass-1") if err := u.saveCurrentRemoteBindingAction(); err != nil { t.Fatalf("saveCurrentRemoteBindingAction() error = %v", err) } profiles := u.availableRemoteProfiles() if len(profiles) != 1 { t.Fatalf("len(availableRemoteProfiles()) = %d, want 1", len(profiles)) } if profiles[0].BaseURL != "https://dav.example.invalid/remote.php/dav" { t.Fatalf("saved profile = %#v, want persisted base URL", profiles[0]) } entries := u.availableRemoteCredentialEntries() var found bool for _, entry := range entries { if entry.Username == "linuscaldwell" && entry.Password == "bellagio-pass-1" { found = true if !slices.Equal(entry.Path, []string{"Crew", "Internet"}) { t.Fatalf("credential path = %v, want [Crew Internet]", entry.Path) } if entry.URL != "https://dav.example.invalid/remote.php/dav" { t.Fatalf("credential URL = %q, want remote.php/dav URL", entry.URL) } } } if !found { t.Fatalf("availableRemoteCredentialEntries() = %#v, want persisted linuscaldwell/bellagio-pass-1 entry", entries) } if got := u.selectedVaultRemoteProfileID; got == "" { t.Fatal("selectedVaultRemoteProfileID = empty, want selected saved profile") } if got := u.selectedVaultRemoteCredentialEntryID; got == "" { t.Fatal("selectedVaultRemoteCredentialEntryID = empty, want selected saved credential entry") } if !u.state.Dirty { t.Fatal("state.Dirty = false, want true after saving binding into vault") } } func TestUIAdvancedSyncMatchingRemoteCredentialEntriesUsesBaseURL(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{ {ID: "entry-1", Title: "Bellagio", Username: "rustyryan", URL: "https://dav.example.invalid/remote.php/dav/", Path: []string{"Crew", "Internet"}}, {ID: "entry-2", Title: "Vault Console", Username: "dannyocean", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}}, {ID: "entry-3", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", URL: "https://dav.example.invalid/remote.php/dav", Path: []string{"Crew", "Internet"}}, }, }) u.syncSourceMode = syncSourceRemote u.syncRemoteBaseURL.SetText("https://dav.example.invalid/remote.php/dav") got := u.matchingAdvancedSyncRemoteCredentialEntries() if len(got) != 2 { t.Fatalf("len(matchingAdvancedSyncRemoteCredentialEntries()) = %d, want 2", len(got)) } if got[0].ID != "entry-1" || got[1].ID != "entry-3" { t.Fatalf("matchingAdvancedSyncRemoteCredentialEntries() = %#v, want Bellagio and Bellagio WebDAV Sign-In matches", got) } } func TestUIAdvancedSyncMatchingRemoteCredentialEntriesUsesMatchingHost(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{ {ID: "entry-1", Title: "Mint WebDAV", Username: "charliecroker", URL: "https://dav.example.invalid", Path: []string{"Crew", "Signals"}}, {ID: "entry-2", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", URL: "https://dav.example.invalid/remote.php/dav", Path: []string{"Crew", "Signals"}}, {ID: "entry-3", Title: "Bank Console", Username: "stevefrezelli", URL: "https://insidejob.example.invalid", Path: []string{"Crew", "Signals"}}, }, }) u.syncSourceMode = syncSourceRemote u.syncRemoteBaseURL.SetText("https://dav.example.invalid/remote.php/dav") got := u.matchingAdvancedSyncRemoteCredentialEntries() if len(got) != 2 { t.Fatalf("len(matchingAdvancedSyncRemoteCredentialEntries()) = %d, want 2", len(got)) } gotIDs := []string{got[0].ID, got[1].ID} slices.Sort(gotIDs) if !slices.Equal(gotIDs, []string{"entry-1", "entry-2"}) { t.Fatalf("matchingAdvancedSyncRemoteCredentialEntries() ids = %v, want [entry-1 entry-2]", gotIDs) } } func TestUIRemoteSyncSetupMatchingCredentialsUsesMatchingHost(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{ {ID: "entry-1", Title: "Mint WebDAV", Username: "charliecroker", URL: "https://dav.example.invalid", Path: []string{"Crew", "Signals"}}, {ID: "entry-2", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", URL: "https://dav.example.invalid/remote.php/dav", Path: []string{"Crew", "Signals"}}, }, }) u.syncDialogPurpose = syncDialogPurposeRemoteSetup u.syncSourceMode = syncSourceRemote u.syncDirection = syncDirectionPush u.syncRemoteBaseURL.SetText("https://dav.example.invalid/remote.php/dav") got := u.matchingAdvancedSyncRemoteCredentialEntries() if len(got) != 2 { t.Fatalf("len(matchingAdvancedSyncRemoteCredentialEntries()) = %d, want 2 in remote setup flow", len(got)) } gotIDs := []string{got[0].ID, got[1].ID} slices.Sort(gotIDs) if !slices.Equal(gotIDs, []string{"entry-1", "entry-2"}) { t.Fatalf("matchingAdvancedSyncRemoteCredentialEntries() ids = %v, want [entry-1 entry-2] in remote setup flow", gotIDs) } } func TestUIOpenRemoteSyncSetupDialogResetsDialogScrollPosition(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{}) u.syncDialogList.Position = layout.Position{First: 2, Offset: 48, BeforeEnd: true} u.openRemoteSyncSetupDialog() if got := u.syncDialogList.Position; got != (layout.Position{}) { t.Fatalf("syncDialogList.Position = %#v, want zero position after opening setup dialog", got) } } func TestUIAdvancedSyncMatchingRemoteCredentialEntriesUsesSavedBindingForCurrentVault(t *testing.T) { t.Parallel() localVaultPath := filepath.Join(t.TempDir(), "bellagio.kdbx") u := newUIWithState("desktop", &uiSession{model: vault.Model{ Entries: []vault.Entry{ {ID: "remote-creds-1", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", Path: []string{"Crew", "Internet"}}, {ID: "entry-2", Title: "Vault Console", Username: "dannyocean", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}}, }, RemoteProfiles: []vault.RemoteProfile{{ ID: "bellagio-webdav", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", }}, }}, statePaths{ DefaultSaveAsPath: filepath.Join(t.TempDir(), "default.kdbx"), RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), }) u.vaultPath.SetText(localVaultPath) u.syncSourceMode = syncSourceRemote u.syncRemoteBaseURL.SetText("https://dav.example.invalid/remote.php/dav") u.syncRemotePath.SetText("files/bellagio/keepass.kdbx") u.recentRemotes = []recentRemoteRecord{{ BaseURL: "https://dav.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", LocalVaultPath: localVaultPath, RemoteProfileID: "bellagio-webdav", CredentialEntryID: "remote-creds-1", }} got := u.matchingAdvancedSyncRemoteCredentialEntries() if len(got) != 1 { t.Fatalf("len(matchingAdvancedSyncRemoteCredentialEntries()) = %d, want 1 from saved binding", len(got)) } if got[0].ID != "remote-creds-1" { t.Fatalf("matchingAdvancedSyncRemoteCredentialEntries() = %#v, want remote-creds-1 from saved binding", got) } } func TestUIApplyAdvancedSyncRemoteCredentialEntryFillsCredentials(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{}) entry := vault.Entry{ ID: "remote-creds-1", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", Password: "bellagio-pass-1", URL: "https://dav.example.invalid/remote.php/dav", } u.applyAdvancedSyncRemoteCredentialEntry(entry) if got := u.syncRemoteUsername.Text(); got != "linuscaldwell" { t.Fatalf("syncRemoteUsername = %q, want linuscaldwell", got) } if got := u.syncRemotePassword.Text(); got != "bellagio-pass-1" { t.Fatalf("syncRemotePassword = %q, want bellagio-pass-1", got) } if got := u.selectedSyncRemoteCredentialEntryID; got != "remote-creds-1" { t.Fatalf("selectedSyncRemoteCredentialEntryID = %q, want remote-creds-1", got) } } func TestUISaveCurrentRemoteBindingActionRequiresCompleteRemoteSignIn(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{}) u.vaultPath.SetText("/tmp/bellagio.kdbx") u.remoteBaseURL.SetText("https://dav.example.invalid/remote.php/dav") u.remotePath.SetText("files/bellagio/keepass.kdbx") if err := u.saveCurrentRemoteBindingAction(); err == nil { t.Fatal("saveCurrentRemoteBindingAction() error = nil, want validation error") } } func TestSwitchToLifecycleSelectionResetsLockedLocalSession(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", summarySession{hasVault: true, locked: true}) u.lifecycleMode = "local" u.vaultPath.SetText("/vaults/bellagio.kdbx") u.remoteBaseURL.SetText("https://dav.crew.example.invalid") u.remotePath.SetText("vaults/remote.kdbx") u.remoteUsername.SetText("dannyocean") u.remotePassword.SetText("topsecret") u.masterPassword.SetText("correct horse battery staple") u.keyFilePath.SetText("/vaults/keyfile.keyx") u.search.SetText("crew") u.state.CurrentPath = []string{"Crew"} u.state.SelectedEntryID = "entry-1" u.state.Section = appstate.SectionTemplates u.state.Dirty = true u.switchToLifecycleSelection("local") if !u.shouldShowLifecycleSetup() { t.Fatal("shouldShowLifecycleSetup() = false, want true after switching away from locked local vault") } if got := u.lifecycleMode; got != "local" { t.Fatalf("lifecycleMode = %q, want local", got) } if got := u.vaultPath.Text(); got != "" { t.Fatalf("vaultPath = %q, want empty", got) } if got := u.remoteBaseURL.Text(); got != "" { t.Fatalf("remoteBaseURL = %q, want empty", got) } if got := u.remotePath.Text(); got != "" { t.Fatalf("remotePath = %q, want empty", got) } if got := u.remoteUsername.Text(); got != "" { t.Fatalf("remoteUsername = %q, want empty", got) } if got := u.remotePassword.Text(); got != "" { t.Fatalf("remotePassword = %q, want empty", got) } if got := u.masterPassword.Text(); got != "" { t.Fatalf("masterPassword = %q, want empty", got) } if got := u.keyFilePath.Text(); got != "" { t.Fatalf("keyFilePath = %q, want empty", got) } if got := u.search.Text(); got != "" { t.Fatalf("search = %q, want empty", got) } if got := u.state.Section; got != appstate.SectionEntries { t.Fatalf("state.Section = %q, want %q", got, appstate.SectionEntries) } if len(u.state.CurrentPath) != 0 { t.Fatalf("state.CurrentPath = %v, want empty", u.state.CurrentPath) } if got := u.state.SelectedEntryID; got != "" { t.Fatalf("state.SelectedEntryID = %q, want empty", got) } if u.state.Dirty { t.Fatal("state.Dirty = true, want false") } } func TestSwitchToLifecycleSelectionResetsLockedRemoteSession(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", summarySession{hasVault: true, locked: true, remote: true}) u.lifecycleMode = "local" u.vaultPath.SetText("/vaults/bellagio.kdbx") u.remoteBaseURL.SetText("https://dav.crew.example.invalid") u.remotePath.SetText("vaults/remote.kdbx") u.remoteUsername.SetText("rustyryan") u.remotePassword.SetText("topsecret") u.switchToLifecycleSelection("remote") if !u.shouldShowLifecycleSetup() { t.Fatal("shouldShowLifecycleSetup() = false, want true after switching away from locked remote vault") } if got := u.lifecycleMode; got != "remote" { t.Fatalf("lifecycleMode = %q, want remote", got) } if got := u.vaultPath.Text(); got != "" { t.Fatalf("vaultPath = %q, want empty", got) } if got := u.remoteBaseURL.Text(); got != "" { t.Fatalf("remoteBaseURL = %q, want empty", got) } if got := u.remotePath.Text(); got != "" { t.Fatalf("remotePath = %q, want empty", got) } if got := u.remoteUsername.Text(); got != "" { t.Fatalf("remoteUsername = %q, want empty", got) } if got := u.remotePassword.Text(); got != "" { t.Fatalf("remotePassword = %q, want empty", got) } } func TestSelectingRecentRemoteSwitchesToRemoteMode(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.lifecycleMode = "local" u.requestMasterPassFocus = false u.recentRemotes = []recentRemoteRecord{{ BaseURL: "https://dav.example.com", Path: "vaults/home.kdbx", }} u.recentRemoteClicks = make([]widget.Clickable, 1) u.recentRemoteClicks[0].Click() gtx := layout.Context{} for u.recentRemoteClicks[0].Clicked(gtx) { if 0 < len(u.recentRemotes) { u.lifecycleMode = "remote" u.applyRecentRemoteRecord(u.recentRemotes[0]) u.requestMasterPassFocus = true } } if got := u.lifecycleMode; got != "remote" { t.Fatalf("lifecycleMode after recent remote click = %q, want remote", got) } if got := u.remoteBaseURL.Text(); got != "https://dav.example.com" { t.Fatalf("remoteBaseURL after recent remote click = %q, want https://dav.example.com", got) } if !u.requestMasterPassFocus { t.Fatal("requestMasterPassFocus after recent remote click = false, want true") } } func TestUILoadingDetailMessageUsesSelectedVault(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.lifecycleMode = "local" u.vaultPath.SetText("/home/julian/vaults/main.kdbx") u.loadingMessage = "Open vault..." got := u.loadingDetailMessage() want := "Target: /home/julian/vaults/main.kdbx" if got != want { t.Fatalf("loadingDetailMessage() = %q, want %q", got, want) } } func TestUILoadingDetailMessageUsesSelectedRemote(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.lifecycleMode = "remote" u.remoteBaseURL.SetText("https://dav.example.com") u.remotePath.SetText("vaults/home.kdbx") u.loadingMessage = "Open remote vault..." got := u.loadingDetailMessage() want := "Target: home.kdbx · dav.example.com (vaults/home.kdbx)" if got != want { t.Fatalf("loadingDetailMessage() = %q, want %q", got, want) } } func TestFriendlyRecentRemoteLabelUsesVaultNameBeforeHost(t *testing.T) { t.Parallel() got := friendlyRecentRemoteLabel(recentRemoteRecord{ BaseURL: "https://dav.example.com/remote.php/webdav/", Path: "vaults/bellagio/mint.kdbx", }) want := "mint.kdbx · dav.example.com" if got != want { t.Fatalf("friendlyRecentRemoteLabel() = %q, want %q", got, want) } } func TestUIRemotePreferencesCurrentSummaryExplainsVaultBackedCredentialFlow(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.remoteBaseURL.SetText("https://dav.example.com") u.remotePath.SetText("vaults/home.kdbx") if got := u.remotePreferencesCurrentSummary(); got != "Current choice: KeePassGO remembers this connection's location only. Remote credentials belong in the vault, not device state." { t.Fatalf("remotePreferencesCurrentSummary() = %q, want location-only vault guidance", got) } u.remoteUsername.SetText("debbieocean") if got := u.remotePreferencesCurrentSummary(); got != "Current choice: the entered WebDAV sign-in is used for this open. To persist it, store it in the vault and bind this vault to the remote profile." { t.Fatalf("remotePreferencesCurrentSummary() = %q, want vault-storage guidance", got) } } func TestUIRemotePreferencesHelpExplainsLocationOnlyRetention(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) if got := u.remotePreferencesAlwaysSavedSummary(); got != "Recent Connections stores only the WebDAV base URL, remote path, and the last group you opened for that connection." { t.Fatalf("remotePreferencesAlwaysSavedSummary() = %q, want saved-fields guidance", got) } if got := u.remotePreferencesRetentionSummary(); got != "KeePassGO keeps up to six recent connections. Store remote credentials in the vault if this connection should persist across devices or reinstalls." { t.Fatalf("remotePreferencesRetentionSummary() = %q, want vault retention guidance", got) } } func TestUIRemotePreferencesPersistenceSummaryExplainsVaultBindingFlow(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) if got := u.remotePreferencesPersistenceSummary(); got != "After a successful remote open, KeePassGO can keep a local cache vault and store the shared remote target plus this user's credential entry in the vault itself." { t.Fatalf("remotePreferencesPersistenceSummary() = %q, want local-first vault-binding guidance", got) } } func TestUIRemotePreferencesHelpDialogToggle(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) gtx := layout.Context{} u.openRemotePrefsHelp.Click() for u.openRemotePrefsHelp.Clicked(gtx) { u.remotePrefsDialogOpen = true } if !u.remotePrefsDialogOpen { t.Fatal("remotePrefsDialogOpen = false after open click, want true") } u.closeRemotePrefsHelp.Click() for u.closeRemotePrefsHelp.Clicked(gtx) { u.remotePrefsDialogOpen = false } if u.remotePrefsDialogOpen { t.Fatal("remotePrefsDialogOpen = true after close click, want false") } } func TestUIRemoteOpenButtonLabelOffersRetryAfterFailure(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.lifecycleMode = "remote" if got := u.remoteOpenButtonLabel(); got != "Create Local Cache" { t.Fatalf("remoteOpenButtonLabel() = %q, want %q", got, "Create Local Cache") } u.state.ErrorMessage = "open remote vault failed: dial tcp timeout" if got := u.remoteOpenButtonLabel(); got != "Retry Local Cache Setup" { t.Fatalf("remoteOpenButtonLabel() after error = %q, want %q", got, "Retry Local Cache Setup") } u.loadingMessage = "Opening..." u.state.ErrorMessage = "" if got := u.remoteOpenButtonLabel(); got != "Creating Local Cache..." { t.Fatalf("remoteOpenButtonLabel() while busy = %q, want %q", got, "Creating Local Cache...") } } func TestUIRemoteLifecycleMessageUsesLocalCacheLanguageForBoundRemote(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.lifecycleMode = "remote" u.applyRecentRemoteRecord(recentRemoteRecord{ BaseURL: "https://dav.example.invalid", Path: "vaults/home.kdbx", LocalVaultPath: "/vaults/cache/home.kdbx", RemoteProfileID: "remote-profile-1", CredentialEntryID: "remote-creds-1", }) if got := u.remoteLifecycleMessage(); got != "Open the local cache for this remote vault, then unlock and sync it with the vault-stored remote settings." { t.Fatalf("remoteLifecycleMessage() = %q, want local-cache guidance", got) } } func TestUIRemoteLifecycleMessageUsesLocalFirstSetupLanguageForFirstRemoteOpen(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.lifecycleMode = "remote" if got := u.remoteLifecycleMessage(); got != "Open a remote vault to create this device's local cache. After the first open, save the remote in the vault to reuse remote sync directly." { t.Fatalf("remoteLifecycleMessage() = %q, want local-first remote setup guidance", got) } } func TestUIRemoteLifecycleSetupSummaryExplainsCacheAndBindingFlow(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) if got := u.remoteLifecycleSetupSummary(); got != "The first remote open creates a local KDBX cache on this device. Save the remote in the vault afterward to turn that cache into a reusable sync target." { t.Fatalf("remoteLifecycleSetupSummary() = %q, want local-cache bootstrap guidance", got) } } func TestUISaveCurrentRemoteBindingHeadingExplainsVaultBinding(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) if got := u.saveCurrentRemoteBindingHeading(); got != "Bind this local vault to the current remote target" { t.Fatalf("saveCurrentRemoteBindingHeading() = %q, want vault binding guidance", got) } } func TestUISaveCurrentRemoteBindingButtonLabelUsesSyncLanguage(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) if got := u.saveCurrentRemoteBindingButtonLabel(); got != "Save Remote In Vault" { t.Fatalf("saveCurrentRemoteBindingButtonLabel() = %q, want sync-target language", got) } } func TestUIRemoteOpenButtonLabelUsesLocalCacheLanguageForBoundRemote(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.lifecycleMode = "remote" u.applyRecentRemoteRecord(recentRemoteRecord{ BaseURL: "https://dav.example.invalid", Path: "vaults/home.kdbx", LocalVaultPath: "/vaults/cache/home.kdbx", RemoteProfileID: "remote-profile-1", CredentialEntryID: "remote-creds-1", }) if got := u.remoteOpenButtonLabel(); got != "Open Cached Vault" { t.Fatalf("remoteOpenButtonLabel() = %q, want %q", got, "Open Cached Vault") } u.state.ErrorMessage = "open remote vault failed: dial tcp timeout" if got := u.remoteOpenButtonLabel(); got != "Retry Cached Vault" { t.Fatalf("remoteOpenButtonLabel() after error = %q, want %q", got, "Retry Cached Vault") } } func TestUISelectedRemoteCardUsesLocalCacheSummaryForBoundRemote(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.lifecycleMode = "remote" u.applyRecentRemoteRecord(recentRemoteRecord{ BaseURL: "https://dav.example.invalid", Path: "vaults/home.kdbx", LocalVaultPath: "/vaults/cache/home.kdbx", RemoteProfileID: "remote-profile-1", CredentialEntryID: "remote-creds-1", }) u.recentRemotes = []recentRemoteRecord{{ BaseURL: "https://dav.example.invalid", Path: "vaults/home.kdbx", LocalVaultPath: "/vaults/cache/home.kdbx", RemoteProfileID: "remote-profile-1", CredentialEntryID: "remote-creds-1", LastGroup: []string{"Root", "Internet"}, }} if got := u.selectedRemoteCardHeading(); got != "CACHED VAULT" { t.Fatalf("selectedRemoteCardHeading() = %q, want %q", got, "CACHED VAULT") } if got := u.selectedRemoteCardPrimaryText(); got != "home.kdbx" { t.Fatalf("selectedRemoteCardPrimaryText() = %q, want %q", got, "home.kdbx") } gotDetails := u.selectedRemoteCardDetailLines() wantDetails := []string{ "/vaults/cache", "Sync target: home.kdbx · dav.example.invalid", "Last group: Root / Internet", } if !slices.Equal(gotDetails, wantDetails) { t.Fatalf("selectedRemoteCardDetailLines() = %v, want %v", gotDetails, wantDetails) } } func TestUISelectedRemoteCardUsesConnectionSummaryWithoutLocalCache(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.lifecycleMode = "remote" u.applyRecentRemoteRecord(recentRemoteRecord{ BaseURL: "https://dav.example.invalid", Path: "vaults/home.kdbx", }) u.recentRemotes = []recentRemoteRecord{{ BaseURL: "https://dav.example.invalid", Path: "vaults/home.kdbx", LastGroup: []string{"Root", "Internet"}, }} if got := u.selectedRemoteCardHeading(); got != "SELECTED CONNECTION" { t.Fatalf("selectedRemoteCardHeading() = %q, want %q", got, "SELECTED CONNECTION") } if got := u.selectedRemoteCardPrimaryText(); got != "home.kdbx · dav.example.invalid" { t.Fatalf("selectedRemoteCardPrimaryText() = %q, want %q", got, "home.kdbx · dav.example.invalid") } gotDetails := u.selectedRemoteCardDetailLines() wantDetails := []string{ "Path: vaults/home.kdbx", "Server: https://dav.example.invalid", "Last group: Root / Internet", } if !slices.Equal(gotDetails, wantDetails) { t.Fatalf("selectedRemoteCardDetailLines() = %v, want %v", gotDetails, wantDetails) } } func TestUIOpenRemoteVaultRestoresLastOpenedGroupForThatConnection(t *testing.T) { t.Parallel() dir := t.TempDir() statePath := filepath.Join(dir, "recent-remotes.json") masterKey := vault.MasterKey{Password: "correct horse battery staple"} var encoded bytes.Buffer if err := vault.SaveKDBXWithKey(&encoded, vault.Model{ Entries: []vault.Entry{ {ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}}, }, }, masterKey); err != nil { t.Fatalf("SaveKDBXWithKey() error = %v", err) } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: _, _ = w.Write(encoded.Bytes()) default: t.Fatalf("unexpected method = %s", r.Method) } })) defer server.Close() paths := statePaths{ DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), RecentRemotesPath: statePath, UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), } first := newUIWithState("desktop", &session.Manager{}, paths) first.lifecycleMode = "remote" first.masterPassword.SetText("correct horse battery staple") first.remoteBaseURL.SetText(server.URL) first.remotePath.SetText("vault.kdbx") if err := first.openRemoteAction(); err != nil { t.Fatalf("openRemoteAction() error = %v", err) } first.state.NavigateToPath([]string{"Root", "Internet"}) first.currentPath = []string{"Root", "Internet"} first.syncedPath = []string{"Root", "Internet"} first.noteCurrentRemotePath() reopened := newUIWithState("desktop", &session.Manager{}, paths) reopened.lifecycleMode = "remote" reopened.masterPassword.SetText("correct horse battery staple") reopened.remoteBaseURL.SetText(server.URL) reopened.remotePath.SetText("vault.kdbx") if err := reopened.openRemoteAction(); err != nil { t.Fatalf("openRemoteAction() error = %v", err) } if got := reopened.state.CurrentPath; !slices.Equal(got, []string{"Root", "Internet"}) { t.Fatalf("state.CurrentPath after reopen = %v, want [Root Internet]", got) } } func TestUIOpenRemoteActionMaterializesLocalCacheAndBinding(t *testing.T) { t.Parallel() dir := t.TempDir() cachePath := filepath.Join(dir, "remote-cache.kdbx") paths := statePaths{ DefaultSaveAsPath: cachePath, RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), } key := vault.MasterKey{Password: "correct horse battery staple"} var encoded bytes.Buffer if err := vault.SaveKDBXWithKey(&encoded, vault.Model{ Entries: []vault.Entry{ {ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}}, }, }, key); err != nil { t.Fatalf("SaveKDBXWithKey() error = %v", err) } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { username, password, ok := r.BasicAuth() if !ok || username != "linuscaldwell" || password != "bellagio-pass-1" { t.Fatalf("BasicAuth() = (%q, %q, %t), want (linuscaldwell, bellagio-pass-1, true)", username, password, ok) } if r.Method != http.MethodGet { t.Fatalf("method = %s, want GET", r.Method) } w.Header().Set("ETag", "\"v1\"") _, _ = w.Write(encoded.Bytes()) })) defer server.Close() u := newUIWithState("desktop", &session.Manager{}, paths) u.lifecycleMode = "remote" u.masterPassword.SetText(key.Password) u.remoteBaseURL.SetText(server.URL) u.remotePath.SetText("vault.kdbx") u.remoteUsername.SetText("linuscaldwell") u.remotePassword.SetText("bellagio-pass-1") if err := u.openRemoteAction(); err != nil { t.Fatalf("openRemoteAction() error = %v", err) } if got := u.vaultPath.Text(); got != cachePath { t.Fatalf("vaultPath = %q, want %q", got, cachePath) } if _, err := os.Stat(cachePath); err != nil { t.Fatalf("Stat(cachePath) error = %v", err) } if got := len(u.recentRemotes); got != 1 { t.Fatalf("len(recentRemotes) = %d, want 1", got) } record := u.recentRemotes[0] if record.LocalVaultPath != cachePath { t.Fatalf("recentRemotes[0].LocalVaultPath = %q, want %q", record.LocalVaultPath, cachePath) } if record.RemoteProfileID == "" || record.CredentialEntryID == "" { t.Fatalf("recentRemotes[0] = %#v, want binding ids populated", record) } var reopened session.Manager if err := reopened.Open(cachePath, key); err != nil { t.Fatalf("Open(cachePath) error = %v", err) } model, err := reopened.Current() if err != nil { t.Fatalf("Current() error = %v", err) } if got := len(model.RemoteProfiles); got != 1 { t.Fatalf("len(RemoteProfiles) = %d, want 1", got) } if got := model.RemoteProfiles[0].BaseURL; got != server.URL { t.Fatalf("RemoteProfiles[0].BaseURL = %q, want %q", got, server.URL) } entry, err := model.EntryByID(record.CredentialEntryID) if err != nil { t.Fatalf("EntryByID(%q) error = %v", record.CredentialEntryID, err) } if entry.Username != "linuscaldwell" || entry.Password != "bellagio-pass-1" { t.Fatalf("credential entry = %#v, want linuscaldwell/bellagio-pass-1", entry) } } func TestUIStartOpenRemoteActionMaterializesLocalCacheAndBinding(t *testing.T) { t.Parallel() dir := t.TempDir() cachePath := filepath.Join(dir, "remote-cache.kdbx") paths := statePaths{ DefaultSaveAsPath: cachePath, RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), } key := vault.MasterKey{Password: "correct horse battery staple"} var encoded bytes.Buffer if err := vault.SaveKDBXWithKey(&encoded, vault.Model{ Entries: []vault.Entry{ {ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}}, }, }, key); err != nil { t.Fatalf("SaveKDBXWithKey() error = %v", err) } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { username, password, ok := r.BasicAuth() if !ok || username != "linuscaldwell" || password != "bellagio-pass-1" { t.Fatalf("BasicAuth() = (%q, %q, %t), want (linuscaldwell, bellagio-pass-1, true)", username, password, ok) } if r.Method != http.MethodGet { t.Fatalf("method = %s, want GET", r.Method) } w.Header().Set("ETag", "\"v1\"") _, _ = w.Write(encoded.Bytes()) })) defer server.Close() u := newUIWithState("desktop", &session.Manager{}, paths) u.lifecycleMode = "remote" u.masterPassword.SetText(key.Password) u.remoteBaseURL.SetText(server.URL) u.remotePath.SetText("vault.kdbx") u.remoteUsername.SetText("linuscaldwell") u.remotePassword.SetText("bellagio-pass-1") u.startOpenRemoteAction() result := waitForBackgroundResult(t, u) u.applyBackgroundResult(result) if got := u.state.ErrorMessage; got != "" { t.Fatalf("ErrorMessage after apply = %q, want empty", got) } if got := u.vaultPath.Text(); got != cachePath { t.Fatalf("vaultPath = %q, want %q", got, cachePath) } if _, err := os.Stat(cachePath); err != nil { t.Fatalf("Stat(cachePath) error = %v", err) } if got := len(u.recentRemotes); got != 1 { t.Fatalf("len(recentRemotes) = %d, want 1", got) } record := u.recentRemotes[0] if record.LocalVaultPath != cachePath { t.Fatalf("recentRemotes[0].LocalVaultPath = %q, want %q", record.LocalVaultPath, cachePath) } if record.RemoteProfileID == "" || record.CredentialEntryID == "" { t.Fatalf("recentRemotes[0] = %#v, want binding ids populated", record) } } func TestDefaultStatePathsUsesProvidedStateDir(t *testing.T) { t.Parallel() base := filepath.Join(t.TempDir(), "keepassgo-state") paths := defaultStatePaths(base) if got := paths.DefaultSaveAsPath; got != filepath.Join(base, "vault.kdbx") { t.Fatalf("DefaultSaveAsPath = %q, want %q", got, filepath.Join(base, "vault.kdbx")) } if got := paths.RecentVaultsPath; got != filepath.Join(base, "recent-vaults.json") { t.Fatalf("RecentVaultsPath = %q, want %q", got, filepath.Join(base, "recent-vaults.json")) } if got := paths.RecentRemotesPath; got != filepath.Join(base, "recent-remotes.json") { t.Fatalf("RecentRemotesPath = %q, want %q", got, filepath.Join(base, "recent-remotes.json")) } if got := paths.SettingsPath; got != filepath.Join(base, "settings.json") { t.Fatalf("SettingsPath = %q, want %q", got, filepath.Join(base, "settings.json")) } if got := paths.UIPreferencesPath; got != filepath.Join(base, "ui-prefs.json") { t.Fatalf("UIPreferencesPath = %q, want %q", got, filepath.Join(base, "ui-prefs.json")) } if got := paths.AutofillCachePath; got != filepath.Join(base, "autofill-cache.json") { t.Fatalf("AutofillCachePath = %q, want %q", got, filepath.Join(base, "autofill-cache.json")) } if got := paths.PendingSharedVaultPath; got != filepath.Join(base, "pending-shared-vault.kdbx") { t.Fatalf("PendingSharedVaultPath = %q, want %q", got, filepath.Join(base, "pending-shared-vault.kdbx")) } if got := paths.PendingSharedVaultNamePath; got != filepath.Join(base, "pending-shared-vault-name.txt") { t.Fatalf("PendingSharedVaultNamePath = %q, want %q", got, filepath.Join(base, "pending-shared-vault-name.txt")) } } func TestImportedVaultDestinationUsesIncomingFilenameInsideDefaultDirectory(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}, statePaths{ DefaultSaveAsPath: filepath.Join(t.TempDir(), "vault.kdbx"), }) got := u.importedVaultDestination("shared-home.kdbx") want := filepath.Join(filepath.Dir(u.defaultSaveAsPath), "shared-home.kdbx") if got != want { t.Fatalf("importedVaultDestination() = %q, want %q", got, want) } } func TestUIImportSharedVaultBytesActionCopiesVaultAndSelectsIt(t *testing.T) { t.Parallel() dir := t.TempDir() paths := statePaths{ DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), } key := vault.MasterKey{Password: "correct horse battery staple"} var encoded bytes.Buffer if err := vault.SaveKDBXWithKey(&encoded, vault.Model{ Entries: []vault.Entry{ {ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}}, }, }, key); err != nil { t.Fatalf("SaveKDBXWithKey() error = %v", err) } u := newUIWithState("phone", &session.Manager{}, paths) u.lifecycleMode = "remote" if err := u.importSharedVaultBytesAction("shared-home.kdbx", encoded.Bytes()); err != nil { t.Fatalf("importSharedVaultBytesAction() error = %v", err) } wantPath := filepath.Join(dir, "shared-home.kdbx") if got := u.vaultPath.Text(); got != wantPath { t.Fatalf("vaultPath = %q, want %q", got, wantPath) } if got := u.lifecycleMode; got != "local" { t.Fatalf("lifecycleMode = %q, want local", got) } if !u.hasSelectedLifecycleTarget() { t.Fatal("hasSelectedLifecycleTarget() = false, want true after import") } if _, err := os.Stat(wantPath); err != nil { t.Fatalf("Stat(imported vault) error = %v", err) } reopened := newUIWithState("phone", &session.Manager{}, paths) reopened.vaultPath.SetText(wantPath) reopened.masterPassword.SetText(key.Password) if err := reopened.openVaultAction(); err != nil { t.Fatalf("openVaultAction(imported) error = %v", err) } reopened.state.NavigateToPath([]string{"Root", "Internet"}) reopened.filter() if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) { t.Fatalf("filteredTitles() = %v, want [Vault Console]", got) } } func TestUIConsumesPendingSharedVaultImportOnStartup(t *testing.T) { t.Parallel() dir := t.TempDir() paths := statePaths{ DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), PendingSharedVaultPath: filepath.Join(dir, "pending-shared-vault.kdbx"), PendingSharedVaultNamePath: filepath.Join(dir, "pending-shared-vault-name.txt"), } key := vault.MasterKey{Password: "correct horse battery staple"} var encoded bytes.Buffer if err := vault.SaveKDBXWithKey(&encoded, vault.Model{ Entries: []vault.Entry{ {ID: "entry-1", Title: "Bellagio", Path: []string{"Crew", "Internet"}}, }, }, key); err != nil { t.Fatalf("SaveKDBXWithKey() error = %v", err) } if err := os.WriteFile(paths.PendingSharedVaultPath, encoded.Bytes(), 0o600); err != nil { t.Fatalf("WriteFile(PendingSharedVaultPath) error = %v", err) } if err := os.WriteFile(paths.PendingSharedVaultNamePath, []byte("crew-shared.kdbx\n"), 0o600); err != nil { t.Fatalf("WriteFile(PendingSharedVaultNamePath) error = %v", err) } u := newUIWithState("phone", &session.Manager{}, paths) wantPath := filepath.Join(dir, "crew-shared.kdbx") if got := u.vaultPath.Text(); got != wantPath { t.Fatalf("vaultPath = %q, want %q", got, wantPath) } if got := u.lifecycleMode; got != "local" { t.Fatalf("lifecycleMode = %q, want local", got) } if !u.hasSelectedLifecycleTarget() { t.Fatal("hasSelectedLifecycleTarget() = false, want true after pending import") } if _, err := os.Stat(paths.PendingSharedVaultPath); !errors.Is(err, os.ErrNotExist) { t.Fatalf("Stat(PendingSharedVaultPath) error = %v, want not exist", err) } if _, err := os.Stat(paths.PendingSharedVaultNamePath); !errors.Is(err, os.ErrNotExist) { t.Fatalf("Stat(PendingSharedVaultNamePath) error = %v, want not exist", err) } reopened := newUIWithState("phone", &session.Manager{}, paths) reopened.vaultPath.SetText(wantPath) reopened.masterPassword.SetText(key.Password) if err := reopened.openVaultAction(); err != nil { t.Fatalf("openVaultAction(imported) error = %v", err) } reopened.state.NavigateToPath([]string{"Crew", "Internet"}) reopened.filter() if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Bellagio"}) { t.Fatalf("filteredTitles() = %v, want [Bellagio]", got) } } func TestUICurrentShareableVaultPathUsesSelectedVaultPath(t *testing.T) { t.Parallel() u := newUIWithSession("phone", &session.Manager{}) u.vaultPath.SetText("/vaults/crew-shared.kdbx") if got := u.currentShareableVaultPath(); got != "/vaults/crew-shared.kdbx" { t.Fatalf("currentShareableVaultPath() = %q, want %q", got, "/vaults/crew-shared.kdbx") } } func TestUIShareCurrentVaultActionSavesAndSharesCurrentVault(t *testing.T) { t.Parallel() session := &saveCaptureSession{} sharer := &captureVaultSharer{} u := newUIWithSession("phone", session) u.vaultSharer = sharer u.vaultPath.SetText("/vaults/crew-shared.kdbx") if err := u.shareCurrentVaultAction(); err != nil { t.Fatalf("shareCurrentVaultAction() error = %v", err) } if session.saveCount != 1 { t.Fatalf("shareCurrentVaultAction() saveCount = %d, want 1", session.saveCount) } if got := sharer.path; got != "/vaults/crew-shared.kdbx" { t.Fatalf("ShareVault path = %q, want %q", got, "/vaults/crew-shared.kdbx") } if got := sharer.title; got != "crew-shared.kdbx" { t.Fatalf("ShareVault title = %q, want %q", got, "crew-shared.kdbx") } } func TestUIShareCurrentVaultActionRequiresVaultPath(t *testing.T) { t.Parallel() u := newUIWithSession("phone", &saveCaptureSession{}, statePaths{ DefaultSaveAsPath: filepath.Join(t.TempDir(), "vault.kdbx"), RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), }) u.vaultSharer = &captureVaultSharer{} err := u.shareCurrentVaultAction() if err == nil || err.Error() != errVaultPathRequired { t.Fatalf("shareCurrentVaultAction() error = %v, want %q", err, errVaultPathRequired) } } func TestDefaultStatePathsUsesEnvironmentStateDirWhenFlagUnset(t *testing.T) { base := filepath.Join(t.TempDir(), "keepassgo-state-env") t.Setenv("KEEPASSGO_STATE_DIR", base) paths := defaultStatePaths("") if got := paths.DefaultSaveAsPath; got != filepath.Join(base, "vault.kdbx") { t.Fatalf("DefaultSaveAsPath = %q, want %q", got, filepath.Join(base, "vault.kdbx")) } if got := paths.RecentVaultsPath; got != filepath.Join(base, "recent-vaults.json") { t.Fatalf("RecentVaultsPath = %q, want %q", got, filepath.Join(base, "recent-vaults.json")) } if got := paths.RecentRemotesPath; got != filepath.Join(base, "recent-remotes.json") { t.Fatalf("RecentRemotesPath = %q, want %q", got, filepath.Join(base, "recent-remotes.json")) } if got := paths.SettingsPath; got != filepath.Join(base, "settings.json") { t.Fatalf("SettingsPath = %q, want %q", got, filepath.Join(base, "settings.json")) } if got := paths.UIPreferencesPath; got != filepath.Join(base, "ui-prefs.json") { t.Fatalf("UIPreferencesPath = %q, want %q", got, filepath.Join(base, "ui-prefs.json")) } if got := paths.AutofillCachePath; got != filepath.Join(base, "autofill-cache.json") { t.Fatalf("AutofillCachePath = %q, want %q", got, filepath.Join(base, "autofill-cache.json")) } } func TestRunActionSynchronizesAutofillCache(t *testing.T) { t.Parallel() dir := t.TempDir() cachePath := filepath.Join(dir, "autofill-cache.json") u := newUIWithSession("desktop", &session.Manager{}, statePaths{ DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), SettingsPath: filepath.Join(dir, "settings.json"), UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), AutofillCachePath: cachePath, }) u.masterPassword.SetText("correct horse battery staple") u.runAction("create vault", u.createVaultAction) u.entryTitle.SetText("Chrome Test") u.entryUsername.SetText("joe") u.entryPassword.SetText("secret") u.entryURL.SetText("https://10.0.2.2:8443/login") u.runAction("save entry", u.saveEntryAction) data, err := os.ReadFile(cachePath) if err != nil { t.Fatalf("ReadFile(cache) error = %v", err) } if !strings.Contains(string(data), "\"host\": \"10.0.2.2\"") { t.Fatalf("cache contents = %s, want host entry", string(data)) } u.runAction("lock vault", u.lockAction) if _, err := os.Stat(cachePath); !os.IsNotExist(err) { t.Fatalf("cache path still exists after lock, stat err = %v", err) } } func TestResolveFlagOrEnvPrefersFlagThenEnvThenFallback(t *testing.T) { t.Setenv("KEEPASSGO_TEST_VALUE", "from-env") if got := resolveFlagOrEnv("from-flag", "KEEPASSGO_TEST_VALUE", "fallback"); got != "from-flag" { t.Fatalf("resolveFlagOrEnv(flag) = %q, want %q", got, "from-flag") } if got := resolveFlagOrEnv("", "KEEPASSGO_TEST_VALUE", "fallback"); got != "from-env" { t.Fatalf("resolveFlagOrEnv(env) = %q, want %q", got, "from-env") } if got := resolveFlagOrEnv("", "KEEPASSGO_TEST_MISSING", "fallback"); got != "fallback" { t.Fatalf("resolveFlagOrEnv(fallback) = %q, want %q", got, "fallback") } } func TestDefaultModeForRuntimeUsesPhoneOnAndroid(t *testing.T) { t.Parallel() if got := defaultModeForRuntime("android"); got != "phone" { t.Fatalf("defaultModeForRuntime(android) = %q, want %q", got, "phone") } if got := defaultModeForRuntime("linux"); got != "desktop" { t.Fatalf("defaultModeForRuntime(linux) = %q, want %q", got, "desktop") } } func TestShouldUsePreviewWindowSizeSkipsAndroid(t *testing.T) { t.Parallel() if got := shouldUsePreviewWindowSize("desktop", "android"); got { t.Fatal("shouldUsePreviewWindowSize(desktop, android) = true, want false") } if got := shouldUsePreviewWindowSize("phone", "android"); got { t.Fatal("shouldUsePreviewWindowSize(phone, android) = true, want false") } if got := shouldUsePreviewWindowSize("desktop", "linux"); !got { t.Fatal("shouldUsePreviewWindowSize(desktop, linux) = false, want true") } } func TestSupportsDesktopFilePicker(t *testing.T) { t.Parallel() if got := supportsDesktopFilePicker("android"); got { t.Fatal("supportsDesktopFilePicker(android) = true, want false") } if got := supportsDesktopFilePicker("linux"); !got { t.Fatal("supportsDesktopFilePicker(linux) = false, want true") } } func TestSupportsSharedVaultImport(t *testing.T) { t.Parallel() if got := supportsSharedVaultImport("android"); !got { t.Fatal("supportsSharedVaultImport(android) = false, want true") } if got := supportsSharedVaultImport("linux"); got { t.Fatal("supportsSharedVaultImport(linux) = true, want false") } } func TestEnterOnLocalLifecycleScreenDefaultsToOpenVault(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "vault.kdbx") var encoded bytes.Buffer if err := vault.SaveKDBX(&encoded, vault.Model{}, "correct horse battery staple"); err != nil { t.Fatalf("SaveKDBX() error = %v", err) } if err := os.WriteFile(path, encoded.Bytes(), 0o600); err != nil { t.Fatalf("WriteFile(vault.kdbx) error = %v", err) } u := newUIWithSession("desktop", &session.Manager{}) u.masterPassword.SetText("correct horse battery staple") u.vaultPath.SetText(path) handled := u.handleKeyPress(key.NameReturn, 0) if !handled { t.Fatal("handleKeyPress(Return) = false, want true") } if got := u.state.StatusMessage; got != "" { t.Fatalf("StatusMessage = %q, want empty after open", got) } } func TestEnterOnRemoteLifecycleScreenDefaultsToOpenRemoteVault(t *testing.T) { t.Parallel() masterKey := vault.MasterKey{Password: "correct horse battery staple"} var encoded bytes.Buffer if err := vault.SaveKDBXWithKey(&encoded, vault.Model{}, masterKey); err != nil { t.Fatalf("SaveKDBXWithKey() error = %v", err) } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Fatalf("unexpected method = %s, want GET", r.Method) } _, _ = w.Write(encoded.Bytes()) })) defer server.Close() u := newUIWithSession("desktop", &session.Manager{}) u.lifecycleMode = "remote" u.masterPassword.SetText("correct horse battery staple") u.remoteBaseURL.SetText(server.URL) u.remotePath.SetText("vault.kdbx") handled := u.handleKeyPress(key.NameReturn, 0) if !handled { t.Fatal("handleKeyPress(Return) = false, want true") } if got := u.state.StatusMessage; got != "" { t.Fatalf("StatusMessage = %q, want empty after remote open", got) } } func TestMasterPasswordPeekResetsAfterOpeningVault(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "vault.kdbx") var encoded bytes.Buffer if err := vault.SaveKDBX(&encoded, vault.Model{ Entries: []vault.Entry{ {ID: "vault-console", Title: "Vault Console", Password: "bellagio-pass-1", Path: []string{"Root", "Internet"}}, }, }, "correct horse battery staple"); err != nil { t.Fatalf("SaveKDBX() error = %v", err) } if err := os.WriteFile(path, encoded.Bytes(), 0o600); err != nil { t.Fatalf("WriteFile(vault.kdbx) error = %v", err) } u := newUIWithSession("desktop", &session.Manager{}) u.masterPassword.SetText("correct horse battery staple") u.vaultPath.SetText(path) u.showPassword = true if err := u.openVaultAction(); err != nil { t.Fatalf("openVaultAction() error = %v", err) } if u.showPassword { t.Fatal("showPassword = true after openVaultAction(), want false") } } func TestPasswordPeekResetsWhenChangingSelectedEntry(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{ {ID: "vault-console", Title: "Vault Console", Password: "bellagio-pass-1", Path: []string{"Root", "Internet"}}, {ID: "bellagio", Title: "Bellagio", Password: "bellagio-pass-2", Path: []string{"Root", "Internet"}}, }, }) u.showEntriesSection() u.state.NavigateToPath([]string{"Root", "Internet"}) u.filter() u.state.SelectedEntryID = "vault-console" u.loadSelectedEntryIntoEditor() u.showPassword = true u.state.SelectedEntryID = "bellagio" u.loadSelectedEntryIntoEditor() if u.showPassword { t.Fatal("showPassword = true after selecting a different entry, want false") } } func TestEnterOnLockedScreenDefaultsToUnlockVault(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.masterPassword.SetText("correct horse battery staple") if err := u.createVaultAction(); err != nil { t.Fatalf("createVaultAction() error = %v", err) } if err := u.lockAction(); err != nil { t.Fatalf("lockAction() error = %v", err) } u.masterPassword.SetText("correct horse battery staple") handled := u.handleKeyPress(key.NameReturn, 0) if !handled { t.Fatal("handleKeyPress(Return) = false, want true while locked") } if got := u.masterPassword.Text(); got != "" { t.Fatalf("masterPassword after unlock = %q, want empty", got) } if !u.isVaultLocked() { t.Fatal("isVaultLocked() = false before background apply, want still locked") } result := waitForBackgroundResult(t, u) if err := result.err; err != nil { t.Fatalf("background unlock prepare error = %v", err) } u.applyBackgroundResult(result) if got := u.state.ErrorMessage; got != "" { t.Fatalf("state.ErrorMessage after unlock apply = %q, want empty", got) } if u.isVaultLocked() { t.Fatal("isVaultLocked() = true, want false after unlock apply") } } func TestUILockedVaultUsesSingleUnlockPaneAndOmitsSearchFocus(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.masterPassword.SetText("correct horse battery staple") if err := u.createVaultAction(); err != nil { t.Fatalf("createVaultAction() error = %v", err) } if err := u.lockAction(); err != nil { t.Fatalf("lockAction() error = %v", err) } if !u.shouldUseLockedSinglePane() { t.Fatal("shouldUseLockedSinglePane() = false, want true while locked") } if got := u.focusOrder(); !slices.Equal(got, []focusID{detailFocusID(detailFieldPassword)}) { t.Fatalf("focusOrder() while locked = %v, want only unlock password focus", got) } } func TestUICopyActionsWriteExpectedClipboardContentsAndSanitizedFeedback(t *testing.T) { t.Parallel() model := vault.Model{ Entries: []vault.Entry{ { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", Password: "bellagio-pass-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: "bellagio-pass-1"}, {name: "url", target: clipboard.TargetURL, label: "copy URL", want: "https://vault.crew.example.invalid"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { u := newUIWithModel("desktop", model) writer := &memoryClipboardWriter{} u.clipboardWriter = writer u.showEntriesSection() u.state.NavigateToPath([]string{"Root", "Internet"}) u.filter() u.state.SelectedEntryID = "vault-console" u.runAction(tt.label, func() error { return u.copySelectedFieldAction(tt.target) }) if writer.content != tt.want { t.Fatalf("clipboard content = %q, want %q", writer.content, tt.want) } if u.state.StatusMessage != tt.label+" complete" { t.Fatalf("state.StatusMessage = %q, want %q", u.state.StatusMessage, tt.label+" complete") } if u.state.ErrorMessage != "" { t.Fatalf("state.ErrorMessage = %q, want empty", u.state.ErrorMessage) } if strings.Contains(u.state.StatusMessage, tt.want) { t.Fatalf("state.StatusMessage = %q, must not contain copied secret or field value %q", u.state.StatusMessage, tt.want) } }) } } func TestUICopyActionSanitizesClipboardBackendErrors(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{ { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }, }, }) u.clipboardWriter = failingClipboardWriter{err: os.ErrPermission} u.showEntriesSection() u.state.NavigateToPath([]string{"Root", "Internet"}) u.filter() u.state.SelectedEntryID = "vault-console" u.runAction("copy password", func() error { return u.copySelectedFieldAction(clipboard.TargetPassword) }) if u.state.ErrorMessage != clipboard.ErrWriteFailed.Error() { t.Fatalf("state.ErrorMessage = %q, want %q", u.state.ErrorMessage, clipboard.ErrWriteFailed.Error()) } if strings.Contains(u.state.ErrorMessage, "bellagio-pass-1") { t.Fatalf("state.ErrorMessage = %q, must not contain copied password", u.state.ErrorMessage) } if u.state.StatusMessage != "" { t.Fatalf("state.StatusMessage = %q, want empty on copy failure", u.state.StatusMessage) } } func TestUIGeneratedPasswordFlowsIntoEditEntryForm(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{ { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", Password: "bellagio-pass-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 == "bellagio-pass-1" { t.Fatal("entryPassword.Text() = bellagio-pass-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: "bellagio-pass-1", Path: []string{"Root", "Internet"}, }, }, }) u.showEntriesSection() u.state.NavigateToPath([]string{"Root", "Internet"}) u.filter() u.state.SelectedEntryID = "vault-console" if got, want := u.detailPasswordValue(), strings.Repeat("•", len("bellagio-pass-1")); got != want { t.Fatalf("detailPasswordValue() hidden = %q, want %q", got, want) } u.showPassword = true if got := u.detailPasswordValue(); got != "bellagio-pass-1" { t.Fatalf("detailPasswordValue() revealed = %q, want %q", got, "bellagio-pass-1") } if err := u.lockAction(); err != nil { t.Fatalf("lockAction() error = %v", err) } if u.showPassword { t.Fatal("showPassword = true after lockAction(), want false") } } func TestUIPasswordTogglePresentationMatchesVisibility(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{}) icon, desc := u.passwordTogglePresentation(false) if icon != u.eyeOffIcon { t.Fatal("passwordTogglePresentation(false) should use slashed eye icon") } if desc != "Show password" { t.Fatalf("passwordTogglePresentation(false) desc = %q, want %q", desc, "Show password") } icon, desc = u.passwordTogglePresentation(true) if icon != u.eyeIcon { t.Fatal("passwordTogglePresentation(true) should use unslashed eye icon") } if desc != "Hide password" { t.Fatalf("passwordTogglePresentation(true) desc = %q, want %q", desc, "Hide password") } } type memoryClipboardWriter struct { content string } func (w *memoryClipboardWriter) WriteText(text string) error { w.content = text return nil } type failingClipboardWriter struct { err error } func (w failingClipboardWriter) WriteText(string) error { return w.err } func TestUILocalLifecycleActionsUpdateVisibleStatusMessages(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.masterPassword.SetText("correct horse battery staple") u.runAction("create vault", u.createVaultAction) if got := u.state.StatusMessage; got != "create vault complete" { t.Fatalf("status after create = %q, want %q", got, "create vault complete") } if got := u.state.ErrorMessage; got != "" { t.Fatalf("error after create = %q, want empty", got) } path := filepath.Join(t.TempDir(), "keepassgo.kdbx") u.saveAsPath.SetText(path) u.runAction("save-as vault", u.saveAsAction) if got := u.state.StatusMessage; got != "save-as vault complete" { t.Fatalf("status after save-as = %q, want %q", got, "save-as vault complete") } if got := u.state.ErrorMessage; got != "" { t.Fatalf("error after save-as = %q, want empty", got) } if err := u.state.UpsertEntry(vault.Entry{ ID: "vault-console", Title: "Vault Console", Username: "dannyocean", Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }); err != nil { t.Fatalf("UpsertEntry() error = %v", err) } u.runAction("save vault", u.saveAction) if got := u.state.StatusMessage; got != "save vault complete" { t.Fatalf("status after save = %q, want %q", got, "save vault complete") } if got := u.state.ErrorMessage; got != "" { t.Fatalf("error after save = %q, want empty", got) } u.runAction("lock vault", u.lockAction) if got := u.state.StatusMessage; got != "lock vault complete" { t.Fatalf("status after lock = %q, want %q", got, "lock vault complete") } if got := u.state.ErrorMessage; got != "" { t.Fatalf("error after lock = %q, want empty", got) } u.masterPassword.SetText("correct horse battery staple") u.runAction("unlock vault", u.unlockAction) if got := u.state.StatusMessage; got != "unlock vault complete" { t.Fatalf("status after unlock = %q, want %q", got, "unlock vault complete") } if got := u.state.ErrorMessage; got != "" { t.Fatalf("error after unlock = %q, want empty", got) } reopened := newUIWithSession("desktop", &session.Manager{}) reopened.masterPassword.SetText("correct horse battery staple") reopened.vaultPath.SetText(path) reopened.runAction("open vault", reopened.openVaultAction) if got := reopened.state.StatusMessage; got != "" { t.Fatalf("status after open = %q, want empty", got) } if got := reopened.state.ErrorMessage; got != "" { t.Fatalf("error after open = %q, want empty", got) } } func TestUIGroupDeletionOnlyAllowsEmptyGroupsAndRequiresConfirmation(t *testing.T) { t.Parallel() t.Run("non-empty group cannot be deleted", func(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{ {ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}}, }, Groups: [][]string{{"Root"}, {"Root", "Internet"}}, }) u.showEntriesSection() u.state.NavigateToPath([]string{"Root", "Internet"}) u.filter() if deletable, reason := u.currentGroupDeletionState(); deletable { t.Fatal("currentGroupDeletionState() deletable = true, want false for non-empty group") } else if !strings.Contains(reason, "contains entries") { t.Fatalf("currentGroupDeletionState() reason = %q, want contains entries guidance", reason) } }) t.Run("empty group requires confirmation before deletion", func(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Groups: [][]string{{"Root"}, {"Root", "Archive"}}, }) u.showEntriesSection() u.state.NavigateToPath([]string{"Root", "Archive"}) u.filter() if deletable, reason := u.currentGroupDeletionState(); !deletable { t.Fatalf("currentGroupDeletionState() = false, want true for empty group: %q", reason) } u.armDeleteCurrentGroupAction() if !u.deleteGroupPendingConfirmation() { t.Fatal("deleteGroupPendingConfirmation() = false, want true after arming delete") } if got := u.state.StatusMessage; !strings.Contains(got, "Confirm deleting empty group") { t.Fatalf("StatusMessage after arming delete = %q, want confirmation guidance", got) } if err := u.deleteCurrentGroupAction(); err != nil { t.Fatalf("deleteCurrentGroupAction() error = %v", err) } if u.deleteGroupPendingConfirmation() { t.Fatal("deleteGroupPendingConfirmation() = true, want false after deletion") } if got := u.currentPath; !slices.Equal(got, []string{"Root"}) { t.Fatalf("currentPath after delete = %v, want [Root]", got) } if got := u.childGroups(); len(got) != 0 { t.Fatalf("childGroups() after delete = %v, want empty", got) } }) } func TestUITemplateSectionEmptyStateStaysProductSpecific(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{}) u.showTemplatesSection() got := u.listEmptyMessage() if got != "Templates are not available in this build." { t.Fatalf("listEmptyMessage() = %q, want templates unavailable copy", got) } } func TestUIListEmptyStateProvidesSectionSpecificGuidance(t *testing.T) { t.Parallel() t.Run("empty group", func(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{ {ID: "entry-1", Title: "Root Entry", Path: []string{"Root"}}, }, }) u.showEntriesSection() u.setCurrentPath([]string{"Root", "Empty Group"}) got := u.listEmptyState() want := emptyState{ Title: "This group is empty", Body: "Add an entry here, search below this point, or open a subgroup.", } if got != want { t.Fatalf("listEmptyState() = %#v, want %#v", got, want) } }) t.Run("recycle search", func(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{}) u.showRecycleBinSection() u.search.SetText("orphaned") got := u.listEmptyState() want := emptyState{ Title: "No matching deleted entries", Body: `No recycle-bin entries match "orphaned". Clear or refine Search vault to look across deleted titles, usernames, URLs, and paths.`, } if got != want { t.Fatalf("listEmptyState() = %#v, want %#v", got, want) } }) t.Run("api tokens", func(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{}) u.showAPITokensSection() got := u.listEmptyState() want := emptyState{ Title: "No API tokens yet", Body: "Issue a token to grant scoped gRPC access to an external tool.", } if got != want { t.Fatalf("listEmptyState() = %#v, want %#v", got, want) } }) } func TestUISearchPlaceholderIsContextual(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{}) if got := u.searchPlaceholder(); got != "Search vault" { t.Fatalf("default searchPlaceholder() = %q, want %q", got, "Search vault") } u.showRecycleBinSection() if got := u.searchPlaceholder(); got != "Search recycle bin" { t.Fatalf("recycle searchPlaceholder() = %q, want %q", got, "Search recycle bin") } u.showAPITokensSection() if got := u.searchPlaceholder(); got != "Search API tokens" { t.Fatalf("api token searchPlaceholder() = %q, want %q", got, "Search API tokens") } u.showAPIAuditSection() if got := u.searchPlaceholder(); got != "Search audit log" { t.Fatalf("api audit searchPlaceholder() = %q, want %q", got, "Search audit log") } u.showAboutSection() if got := u.searchPlaceholder(); got != "Search disabled on About" { t.Fatalf("about searchPlaceholder() = %q, want %q", got, "Search disabled on About") } } func TestShowAboutSection(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{{ID: "entry-1", Title: "Bellagio", Path: []string{"Crew"}}}, }) u.mainMenuOpen = true u.state.CurrentPath = []string{"Crew"} u.state.SelectedEntryID = "entry-1" u.showAboutSection() if got := u.state.Section; got != appstate.SectionAbout { t.Fatalf("state.Section = %q, want %q", got, appstate.SectionAbout) } if u.mainMenuOpen { t.Fatal("mainMenuOpen = true, want false") } if len(u.state.CurrentPath) != 0 { t.Fatalf("state.CurrentPath = %v, want empty", u.state.CurrentPath) } if got := u.state.SelectedEntryID; got != "" { t.Fatalf("state.SelectedEntryID = %q, want empty", got) } } func TestCurrentAppVersion(t *testing.T) { t.Parallel() previous := appVersion t.Cleanup(func() { appVersion = previous }) appVersion = "" if got := currentAppVersion(); got != "dev" { t.Fatalf("currentAppVersion() with empty version = %q, want dev", got) } appVersion = " v0.0.1 " if got := currentAppVersion(); got != "v0.0.1" { t.Fatalf("currentAppVersion() with linker version = %q, want v0.0.1", got) } } func TestUIAPIPolicyTargetActionsUseCurrentContext(t *testing.T) { t.Parallel() u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{ {ID: "lights", Title: "Security Office", Path: []string{"Crew", "bashertarr"}}, }, }) u.state.NavigateToPath([]string{"Crew", "bashertarr"}) u.filter() u.state.SelectedEntryID = "lights" if err := u.useCurrentGroupForPolicyAction(); err != nil { t.Fatalf("useCurrentGroupForPolicyAction() error = %v", err) } if got := u.apiPolicyPath.Text(); got != "bashertarr" { t.Fatalf("apiPolicyPath.Text() = %q, want %q", got, "bashertarr") } if !u.apiPolicyGroupScopeW.Value { t.Fatal("apiPolicyGroupScopeW.Value = false, want true") } if err := u.useSelectedEntryForPolicyAction(); err != nil { t.Fatalf("useSelectedEntryForPolicyAction() error = %v", err) } if got := u.apiPolicyEntryID.Text(); got != "lights" { t.Fatalf("apiPolicyEntryID.Text() = %q, want %q", got, "lights") } if u.apiPolicyGroupScopeW.Value { t.Fatal("apiPolicyGroupScopeW.Value = true, want false") } if err := u.clearAPIPolicyTargetAction(); err != nil { t.Fatalf("clearAPIPolicyTargetAction() error = %v", err) } if got := u.apiPolicyPath.Text(); got != "" { t.Fatalf("apiPolicyPath.Text() = %q, want empty", got) } if got := u.apiPolicyEntryID.Text(); got != "" { t.Fatalf("apiPolicyEntryID.Text() = %q, want empty", got) } } func TestUIVisibleBreadcrumbsCompressesAggressivelyOnPhone(t *testing.T) { t.Parallel() u := newUIWithModel("phone", vault.Model{}) gotCrumbs, gotIndices := u.visibleBreadcrumbs([]string{"Root", "Infrastructure"}) if !slices.Equal(gotCrumbs, []string{"/", "Root", "Infrastructure"}) { t.Fatalf("visibleBreadcrumbs() crumbs = %v, want [\"/\" Root Infrastructure]", gotCrumbs) } if !slices.Equal(gotIndices, []int{0, 1, 2}) { t.Fatalf("visibleBreadcrumbs() indices = %v, want [0 1 2]", gotIndices) } gotCrumbs, gotIndices = u.visibleBreadcrumbs([]string{"Root", "Infrastructure", "SSH"}) if !slices.Equal(gotCrumbs, []string{"/", "…", "SSH"}) { t.Fatalf("visibleBreadcrumbs() deep crumbs = %v, want [\"/\" \"…\" SSH]", gotCrumbs) } if !slices.Equal(gotIndices, []int{0, 2, 3}) { t.Fatalf("visibleBreadcrumbs() deep indices = %v, want [0 2 3]", gotIndices) } } func TestUIPhoneVisibleBreadcrumbsKeepParentForTwoSegmentPath(t *testing.T) { t.Parallel() u := newUIWithModel("phone", vault.Model{}) gotCrumbs, gotIndices := u.visibleBreadcrumbs([]string{"Crew", "Internet"}) if !slices.Equal(gotCrumbs, []string{"/", "Crew", "Internet"}) { t.Fatalf("visibleBreadcrumbs() crumbs = %v, want [\"/\" Crew Internet]", gotCrumbs) } if !slices.Equal(gotIndices, []int{0, 1, 2}) { t.Fatalf("visibleBreadcrumbs() indices = %v, want [0 1 2]", gotIndices) } } func TestUILocalLifecycleActionErrorsAreVisibleAndSpecific(t *testing.T) { t.Parallel() t.Run("save without configured path", func(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}, statePaths{ DefaultSaveAsPath: filepath.Join(t.TempDir(), "default-save-path.kdbx"), RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), UIPreferencesPath: filepath.Join(t.TempDir(), "ui-preferences.json"), }) u.masterPassword.SetText("correct horse battery staple") u.runAction("create vault", u.createVaultAction) u.runAction("save vault", u.saveAction) if got := u.state.StatusMessage; got != "save vault complete" { t.Fatalf("status after save = %q, want %q", got, "save vault complete") } if got := u.state.ErrorMessage; got != "" { t.Fatalf("error after save = %q, want empty", got) } }) t.Run("save-as uses default target path", func(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}, statePaths{ DefaultSaveAsPath: filepath.Join(t.TempDir(), "default-save-path.kdbx"), RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), UIPreferencesPath: filepath.Join(t.TempDir(), "ui-preferences.json"), }) u.masterPassword.SetText("correct horse battery staple") u.defaultSaveAsPath = filepath.Join(t.TempDir(), "default-save-as.kdbx") u.runAction("create vault", u.createVaultAction) u.runAction("save-as vault", u.saveAsAction) if got := u.state.StatusMessage; got != "save-as vault complete" { t.Fatalf("status after save-as = %q, want %q", got, "save-as vault complete") } if got := u.state.ErrorMessage; got != "" { t.Fatalf("error after save-as = %q, want empty", got) } if _, err := os.Stat(u.defaultSaveAsPath); err != nil { t.Fatalf("Stat(defaultSaveAsPath) error = %v", err) } }) t.Run("open without target path", func(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.masterPassword.SetText("correct horse battery staple") u.vaultPath.SetText("") u.runAction("open vault", u.openVaultAction) if got := u.state.StatusMessage; got != "" { t.Fatalf("status after failed open = %q, want empty", got) } if got := u.state.ErrorMessage; got != "vault path is required" { t.Fatalf("error after failed open = %q, want %q", got, "vault path is required") } }) t.Run("open unreadable path", func(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.masterPassword.SetText("correct horse battery staple") u.vaultPath.SetText(filepath.Join(t.TempDir(), "missing.kdbx")) u.runAction("open vault", u.openVaultAction) if got := u.state.StatusMessage; got != "" { t.Fatalf("status after unreadable open = %q, want empty", got) } if got := u.state.ErrorMessage; got == "" || !strings.Contains(got, "read ") { t.Fatalf("error after unreadable open = %q, want read failure", got) } }) t.Run("open decode failure", func(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "corrupt.kdbx") if err := os.WriteFile(path, []byte("not-a-kdbx"), 0o600); err != nil { t.Fatalf("WriteFile(corrupt) error = %v", err) } u := newUIWithSession("desktop", &session.Manager{}) u.masterPassword.SetText("correct horse battery staple") u.vaultPath.SetText(path) u.runAction("open vault", u.openVaultAction) if got := u.state.StatusMessage; got != "" { t.Fatalf("status after decode failure = %q, want empty", got) } if got := u.state.ErrorMessage; got == "" || !strings.Contains(got, "decode kdbx") { t.Fatalf("error after decode failure = %q, want decode kdbx failure", got) } }) t.Run("open invalid master key", func(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "vault.kdbx") var encoded bytes.Buffer if err := vault.SaveKDBX(&encoded, vault.Model{}, "correct horse battery staple"); err != nil { t.Fatalf("SaveKDBX() error = %v", err) } if err := os.WriteFile(path, encoded.Bytes(), 0o600); err != nil { t.Fatalf("WriteFile(vault) error = %v", err) } u := newUIWithSession("desktop", &session.Manager{}) u.masterPassword.SetText("wrong password") u.vaultPath.SetText(path) u.runAction("open vault", u.openVaultAction) if got := u.state.StatusMessage; got != "" { t.Fatalf("status after invalid master open = %q, want empty", got) } if got := u.state.ErrorMessage; !strings.Contains(got, vault.ErrInvalidMasterKey.Error()) { t.Fatalf("error after invalid master open = %q, want %q", got, vault.ErrInvalidMasterKey.Error()) } }) t.Run("unlock invalid master key", func(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.masterPassword.SetText("correct horse battery staple") if err := u.createVaultAction(); err != nil { t.Fatalf("createVaultAction() error = %v", err) } if err := u.lockAction(); err != nil { t.Fatalf("lockAction() error = %v", err) } u.masterPassword.SetText("wrong password") u.runAction("unlock vault", u.unlockAction) if got := u.state.StatusMessage; got != "" { t.Fatalf("status after invalid unlock = %q, want empty", got) } if got := u.state.ErrorMessage; !strings.Contains(got, vault.ErrInvalidMasterKey.Error()) { t.Fatalf("error after invalid unlock = %q, want %q", got, vault.ErrInvalidMasterKey.Error()) } }) } func TestUILocalLifecycleActionsClearStaleMessagesOnSuccess(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.masterPassword.SetText("correct horse battery staple") u.runAction("save vault", u.saveAction) if u.state.ErrorMessage == "" { t.Fatal("error after failed save = empty, want visible failure") } u.runAction("create vault", u.createVaultAction) if got := u.state.ErrorMessage; got != "" { t.Fatalf("error after create = %q, want cleared", got) } if got := u.state.StatusMessage; got != "create vault complete" { t.Fatalf("status after create = %q, want %q", got, "create vault complete") } } func TestUICurrentMasterKeyReportsUnreadableKeyFile(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.keyFilePath.SetText(filepath.Join(t.TempDir(), "missing.key")) _, err := u.currentMasterKey() if err == nil { t.Fatal("currentMasterKey() error = nil, want read failure") } if !errors.Is(err, os.ErrNotExist) { t.Fatalf("currentMasterKey() error = %v, want os.ErrNotExist", err) } } func writeKDBXMainTestFile(t *testing.T, path string, model vault.Model, key vault.MasterKey) { t.Helper() var encoded bytes.Buffer if err := vault.SaveKDBXWithKey(&encoded, model, key); err != nil { t.Fatalf("SaveKDBXWithKey(%s) error = %v", path, err) } if err := os.WriteFile(path, encoded.Bytes(), 0o600); err != nil { t.Fatalf("WriteFile(%s) error = %v", path, err) } } type mainStubApprovalManager struct { pending []apiapproval.Request lastID string lastOutcome apiapproval.Outcome } func (m mainStubApprovalManager) Pending() []apiapproval.Request { return append([]apiapproval.Request(nil), m.pending...) } func (m *mainStubApprovalManager) Resolve(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) { m.lastID = id m.lastOutcome = outcome return apiapproval.Request{ID: id}, nil, nil }