diff --git a/main.go b/main.go index 6bddb39..57e0ecd 100644 --- a/main.go +++ b/main.go @@ -207,271 +207,273 @@ const ( ) type ui struct { - mode string - theme *material.Theme - fileExplorer *explorer.Explorer - logoHorizontal paint.ImageOp - splashSquare paint.ImageOp - search widget.Editor - vaultPath widget.Editor - saveAsPath widget.Editor - remoteBaseURL widget.Editor - remotePath widget.Editor - remoteUsername widget.Editor - remotePassword widget.Editor - masterPassword widget.Editor - keyFilePath widget.Editor - apiTokenName widget.Editor - apiTokenClientName widget.Editor - apiTokenExpiresAt widget.Editor - apiPolicyOperation widget.Editor - apiPolicyPath widget.Editor - apiPolicyEntryID widget.Editor - securityCipher widget.Editor - securityKDF widget.Editor - entryID widget.Editor - entryTitle widget.Editor - entryUsername widget.Editor - entryPassword widget.Editor - entryURL widget.Editor - entryNotes widget.Editor - entryTags widget.Editor - entryPath widget.Editor - entryFields widget.Editor - customFieldKeys []widget.Editor - customFieldValues []widget.Editor - historyIndex widget.Editor - groupName widget.Editor - groupParentPath widget.Editor - passwordProfile widget.Editor - attachmentName widget.Editor - attachmentPath widget.Editor - exportAttachmentPath widget.Editor - autofillBrowserAllowlist widget.Editor - autofillAppAllowlist widget.Editor - autofillPackageRules widget.Editor - list widget.List - groupList widget.List - detailList widget.List - apiPolicyList widget.List - lifecycleList widget.List - phonePanelList widget.List - securityDialogList widget.List - remotePrefsDialogList widget.List - recentVaultListState widget.List - recentRemoteListState widget.List - copyUser widget.Clickable - copyPass widget.Clickable - copyURL widget.Clickable - lockVault widget.Clickable - unlockVault widget.Clickable - createVault widget.Clickable - openVault widget.Clickable - saveVault widget.Clickable - saveAsVault widget.Clickable - openRemote widget.Clickable - changeMasterKey widget.Clickable - synchronizeVault widget.Clickable - toggleSyncMenu widget.Clickable - toggleMainMenu widget.Clickable - openAdvancedSync widget.Clickable - openSecuritySettings widget.Clickable - openRemotePrefsHelp widget.Clickable - closeAdvancedSync widget.Clickable - closeSecuritySettings widget.Clickable - closeRemotePrefsHelp widget.Clickable - runAdvancedSync widget.Clickable - saveSecuritySettings widget.Clickable - settingsDensityDense widget.Clickable - settingsDensityComfortable widget.Clickable - settingsContrastStandard widget.Clickable - settingsContrastHigh widget.Clickable - settingsReducedMotionOff widget.Clickable - settingsReducedMotionOn widget.Clickable - settingsKeyboardFocusStandard widget.Clickable - settingsKeyboardFocusProminent widget.Clickable - showSettingsSyncLocal widget.Clickable - showSettingsSyncRemote widget.Clickable - showSettingsSyncPull widget.Clickable - showSettingsSyncPush widget.Clickable - editEntry widget.Clickable - cancelEdit widget.Clickable - pickVaultPath widget.Clickable - pickKeyFile widget.Clickable - pickSyncLocalPath widget.Clickable - clearVaultSelection widget.Clickable - clearRemoteSelection widget.Clickable - dismissBanner widget.Clickable - addEntry widget.Clickable - saveEntry widget.Clickable - duplicateEntry widget.Clickable - deleteEntry widget.Clickable - restoreEntry widget.Clickable - saveTemplate widget.Clickable - deleteTemplate widget.Clickable - instantiateTemplate widget.Clickable - addAttachment widget.Clickable - replaceAttachment widget.Clickable - removeAttachment widget.Clickable - exportAttachment widget.Clickable - restoreHistory widget.Clickable - generatePassword widget.Clickable - goToRootGroup widget.Clickable - goToParentGroup widget.Clickable - createGroup widget.Clickable - moveGroup widget.Clickable - renameGroup widget.Clickable - deleteGroup widget.Clickable - confirmDeleteGroup widget.Clickable - cancelDeleteGroup widget.Clickable - addCustomField widget.Clickable - toggleGroupControls widget.Clickable - toggleLifecycleAdvanced widget.Clickable - toggleHistory widget.Clickable - togglePasswordInline widget.Clickable - toggleSyncPassword widget.Clickable - setStatusBannerShort widget.Clickable - setStatusBannerStandard widget.Clickable - setStatusBannerLong widget.Clickable - showAllAutofillNotices widget.Clickable - showApprovalAutofillOnly widget.Clickable - hideAutofillNotices widget.Clickable - showEntries widget.Clickable - showTemplates widget.Clickable - showRecycle widget.Clickable - showAPITokens widget.Clickable - showAPIAudit widget.Clickable - showAbout widget.Clickable - showLocalLifecycle widget.Clickable - showRemoteLifecycle widget.Clickable - showSyncLocal widget.Clickable - showSyncRemote widget.Clickable - showSyncPull widget.Clickable - showSyncPush widget.Clickable - showAutofillApprovalAsk widget.Clickable - showAutofillApprovalAllow widget.Clickable - showAutofillApprovalBlock widget.Clickable - allowApproval widget.Clickable - denyApproval widget.Clickable - cancelApproval widget.Clickable - cancelLifecycleProgress widget.Clickable - retryLifecycleOpen widget.Clickable - approvalPermanent widget.Bool - apiPolicyAllow widget.Bool - apiPolicyGroupScopeW widget.Bool - apiTokenDisabled widget.Bool - settingsGroupControls widget.Bool - settingsLifecycleAdvanced widget.Bool - settingsHistory widget.Bool - settingsDenseLayout widget.Bool - entryClicks []widget.Clickable - apiTokenClicks []widget.Clickable - apiPolicyRemoves []widget.Clickable - apiAuditClicks []widget.Clickable - apiAuditTokenFilters []widget.Clickable - apiAuditDecisionFilters []widget.Clickable - apiAuditOperationFilters []widget.Clickable - clearAPIAuditFilters widget.Clickable - historyClicks []widget.Clickable - attachmentClicks []widget.Clickable - breadcrumbs []widget.Clickable - groupClicks []widget.Clickable - recentVaultClicks []widget.Clickable - recentRemoteClicks []widget.Clickable - removeCustomFields []widget.Clickable - state appstate.State - visible []entry - currentPath []string - syncedPath []string - selectedHistoryIndex int - showPassword bool - generatedPasswordDraft bool - togglePassword widget.Clickable - copyAPITokenSecret widget.Clickable - issueAPIToken widget.Clickable - saveAPIToken widget.Clickable - rotateAPIToken widget.Clickable - disableAPIToken widget.Clickable - revokeAPIToken widget.Clickable - deleteAPIToken widget.Clickable - useCurrentGroupForPolicy widget.Clickable - useSelectedEntryForPolicy widget.Clickable - clearAPIPolicyTarget widget.Clickable - addAPIPolicyRule widget.Clickable - phoneSplit widget.Float - splitDrag gesture.Drag - splitBase float32 - splitStartY float32 - phoneSpan int - phoneGroupBrowserExpanded bool - eyeIcon *widget.Icon - eyeOffIcon *widget.Icon - copyIcon *widget.Icon - expandMoreIcon *widget.Icon - expandLessIcon *widget.Icon - chevronRightIcon *widget.Icon - chevronDownIcon *widget.Icon - settingsIcon *widget.Icon - menuIcon *widget.Icon - clipboardWriter clipboard.Writer - loadingMessage string - loadingActionLabel string - lifecycleMode string - syncSourceMode syncSourceMode - syncDirection syncDirection - syncLocalImportName string - syncLocalImportContent []byte - syncLocalPath widget.Editor - syncRemoteBaseURL widget.Editor - syncRemotePath widget.Editor - syncRemoteUsername widget.Editor - syncRemotePassword widget.Editor - syncDialogOpen bool - syncMenuOpen bool - mainMenuOpen bool - selectedRemoteConnection bool - securityDialogOpen bool - remotePrefsDialogOpen bool - showSyncPassword bool - keyboardFocus focusID - defaultSaveAsPath string - recentVaultsPath string - settingsPath string - uiPreferencesPath string - recentRemotesPath string - autofillCachePath string - editingEntry bool - syncDefaultSourceMode syncSourceMode - syncDefaultDirection syncDirection - groupControlsHidden bool - lifecycleAdvancedHidden bool - historyHidden bool - denseLayout bool - statusBannerTTL time.Duration - autofillNoticePreference autofillNoticeMode - autofillFirstFillApprovalMode autofillFirstFillApprovalMode - accessibilityPrefs accessibilityPreferences - settingsDraft settingsDraft - recentVaults []string - recentRemotes []recentRemoteRecord - recentVaultGroups map[string][]string - recentVaultUsedAt map[string]time.Time - entriesState entriesSectionState - deleteGroupPath []string - apiPolicyGroupScope bool - apiTokenSecret string - selectedAuditIndex int - statusExpiresAt time.Time - now func() time.Time - apiHost *api.Host - auditLog *apiaudit.Log - grpcAddress string - backgroundResults chan backgroundActionResult - backgroundActionSerial int - activeBackgroundAction int - lastLifecycleAction string - requestMasterPassFocus bool - invalidate func() + mode string + theme *material.Theme + fileExplorer *explorer.Explorer + logoHorizontal paint.ImageOp + splashSquare paint.ImageOp + search widget.Editor + vaultPath widget.Editor + saveAsPath widget.Editor + remoteBaseURL widget.Editor + remotePath widget.Editor + remoteUsername widget.Editor + remotePassword widget.Editor + masterPassword widget.Editor + keyFilePath widget.Editor + apiTokenName widget.Editor + apiTokenClientName widget.Editor + apiTokenExpiresAt widget.Editor + apiPolicyOperation widget.Editor + apiPolicyPath widget.Editor + apiPolicyEntryID widget.Editor + securityCipher widget.Editor + securityKDF widget.Editor + entryID widget.Editor + entryTitle widget.Editor + entryUsername widget.Editor + entryPassword widget.Editor + entryURL widget.Editor + entryNotes widget.Editor + entryTags widget.Editor + entryPath widget.Editor + entryFields widget.Editor + customFieldKeys []widget.Editor + customFieldValues []widget.Editor + historyIndex widget.Editor + groupName widget.Editor + groupParentPath widget.Editor + passwordProfile widget.Editor + attachmentName widget.Editor + attachmentPath widget.Editor + exportAttachmentPath widget.Editor + autofillBrowserAllowlist widget.Editor + autofillAppAllowlist widget.Editor + autofillPackageRules widget.Editor + list widget.List + groupList widget.List + detailList widget.List + apiPolicyList widget.List + lifecycleList widget.List + phonePanelList widget.List + securityDialogList widget.List + remotePrefsDialogList widget.List + recentVaultListState widget.List + recentRemoteListState widget.List + copyUser widget.Clickable + copyPass widget.Clickable + copyURL widget.Clickable + lockVault widget.Clickable + unlockVault widget.Clickable + createVault widget.Clickable + openVault widget.Clickable + saveVault widget.Clickable + saveAsVault widget.Clickable + openRemote widget.Clickable + changeMasterKey widget.Clickable + synchronizeVault widget.Clickable + toggleSyncMenu widget.Clickable + toggleMainMenu widget.Clickable + openAdvancedSync widget.Clickable + openSecuritySettings widget.Clickable + openRemotePrefsHelp widget.Clickable + closeAdvancedSync widget.Clickable + closeSecuritySettings widget.Clickable + closeRemotePrefsHelp widget.Clickable + runAdvancedSync widget.Clickable + saveSecuritySettings widget.Clickable + settingsDensityDense widget.Clickable + settingsDensityComfortable widget.Clickable + settingsContrastStandard widget.Clickable + settingsContrastHigh widget.Clickable + settingsReducedMotionOff widget.Clickable + settingsReducedMotionOn widget.Clickable + settingsKeyboardFocusStandard widget.Clickable + settingsKeyboardFocusProminent widget.Clickable + showSettingsSyncLocal widget.Clickable + showSettingsSyncRemote widget.Clickable + showSettingsSyncPull widget.Clickable + showSettingsSyncPush widget.Clickable + editEntry widget.Clickable + cancelEdit widget.Clickable + pickVaultPath widget.Clickable + pickKeyFile widget.Clickable + pickSyncLocalPath widget.Clickable + clearVaultSelection widget.Clickable + clearRemoteSelection widget.Clickable + dismissBanner widget.Clickable + addEntry widget.Clickable + saveEntry widget.Clickable + duplicateEntry widget.Clickable + deleteEntry widget.Clickable + restoreEntry widget.Clickable + saveTemplate widget.Clickable + deleteTemplate widget.Clickable + instantiateTemplate widget.Clickable + addAttachment widget.Clickable + replaceAttachment widget.Clickable + removeAttachment widget.Clickable + exportAttachment widget.Clickable + restoreHistory widget.Clickable + generatePassword widget.Clickable + goToRootGroup widget.Clickable + goToParentGroup widget.Clickable + createGroup widget.Clickable + moveGroup widget.Clickable + renameGroup widget.Clickable + deleteGroup widget.Clickable + confirmDeleteGroup widget.Clickable + cancelDeleteGroup widget.Clickable + addCustomField widget.Clickable + toggleGroupControls widget.Clickable + toggleLifecycleAdvanced widget.Clickable + toggleHistory widget.Clickable + togglePasswordInline widget.Clickable + toggleSyncPassword widget.Clickable + setStatusBannerShort widget.Clickable + setStatusBannerStandard widget.Clickable + setStatusBannerLong widget.Clickable + showAllAutofillNotices widget.Clickable + showApprovalAutofillOnly widget.Clickable + hideAutofillNotices widget.Clickable + showEntries widget.Clickable + showTemplates widget.Clickable + showRecycle widget.Clickable + showAPITokens widget.Clickable + showAPIAudit widget.Clickable + showAbout widget.Clickable + showLocalLifecycle widget.Clickable + showRemoteLifecycle widget.Clickable + showSyncLocal widget.Clickable + showSyncRemote widget.Clickable + showSyncPull widget.Clickable + showSyncPush widget.Clickable + showAutofillApprovalAsk widget.Clickable + showAutofillApprovalAllow widget.Clickable + showAutofillApprovalBlock widget.Clickable + allowApproval widget.Clickable + denyApproval widget.Clickable + cancelApproval widget.Clickable + cancelLifecycleProgress widget.Clickable + retryLifecycleOpen widget.Clickable + approvalPermanent widget.Bool + apiPolicyAllow widget.Bool + apiPolicyGroupScopeW widget.Bool + apiTokenDisabled widget.Bool + settingsGroupControls widget.Bool + settingsLifecycleAdvanced widget.Bool + settingsHistory widget.Bool + settingsDenseLayout widget.Bool + entryClicks []widget.Clickable + apiTokenClicks []widget.Clickable + apiPolicyRemoves []widget.Clickable + apiAuditClicks []widget.Clickable + apiAuditTokenFilters []widget.Clickable + apiAuditDecisionFilters []widget.Clickable + apiAuditOperationFilters []widget.Clickable + clearAPIAuditFilters widget.Clickable + historyClicks []widget.Clickable + attachmentClicks []widget.Clickable + breadcrumbs []widget.Clickable + groupClicks []widget.Clickable + recentVaultClicks []widget.Clickable + recentRemoteClicks []widget.Clickable + removeCustomFields []widget.Clickable + state appstate.State + visible []entry + currentPath []string + syncedPath []string + selectedHistoryIndex int + showPassword bool + generatedPasswordDraft bool + togglePassword widget.Clickable + copyAPITokenSecret widget.Clickable + issueAPIToken widget.Clickable + saveAPIToken widget.Clickable + rotateAPIToken widget.Clickable + disableAPIToken widget.Clickable + revokeAPIToken widget.Clickable + deleteAPIToken widget.Clickable + useCurrentGroupForPolicy widget.Clickable + useSelectedEntryForPolicy widget.Clickable + clearAPIPolicyTarget widget.Clickable + addAPIPolicyRule widget.Clickable + phoneSplit widget.Float + splitDrag gesture.Drag + splitBase float32 + splitStartY float32 + phoneSpan int + phoneGroupBrowserExpanded bool + eyeIcon *widget.Icon + eyeOffIcon *widget.Icon + copyIcon *widget.Icon + expandMoreIcon *widget.Icon + expandLessIcon *widget.Icon + chevronRightIcon *widget.Icon + chevronDownIcon *widget.Icon + settingsIcon *widget.Icon + menuIcon *widget.Icon + clipboardWriter clipboard.Writer + loadingMessage string + loadingActionLabel string + lifecycleMode string + syncSourceMode syncSourceMode + syncDirection syncDirection + syncLocalImportName string + syncLocalImportContent []byte + syncLocalPath widget.Editor + syncRemoteBaseURL widget.Editor + syncRemotePath widget.Editor + syncRemoteUsername widget.Editor + syncRemotePassword widget.Editor + syncDialogOpen bool + syncMenuOpen bool + mainMenuOpen bool + selectedRemoteConnection bool + selectedVaultRemoteProfileID string + selectedVaultRemoteCredentialEntryID string + securityDialogOpen bool + remotePrefsDialogOpen bool + showSyncPassword bool + keyboardFocus focusID + defaultSaveAsPath string + recentVaultsPath string + settingsPath string + uiPreferencesPath string + recentRemotesPath string + autofillCachePath string + editingEntry bool + syncDefaultSourceMode syncSourceMode + syncDefaultDirection syncDirection + groupControlsHidden bool + lifecycleAdvancedHidden bool + historyHidden bool + denseLayout bool + statusBannerTTL time.Duration + autofillNoticePreference autofillNoticeMode + autofillFirstFillApprovalMode autofillFirstFillApprovalMode + accessibilityPrefs accessibilityPreferences + settingsDraft settingsDraft + recentVaults []string + recentRemotes []recentRemoteRecord + recentVaultGroups map[string][]string + recentVaultUsedAt map[string]time.Time + entriesState entriesSectionState + deleteGroupPath []string + apiPolicyGroupScope bool + apiTokenSecret string + selectedAuditIndex int + statusExpiresAt time.Time + now func() time.Time + apiHost *api.Host + auditLog *apiaudit.Log + grpcAddress string + backgroundResults chan backgroundActionResult + backgroundActionSerial int + activeBackgroundAction int + lastLifecycleAction string + requestMasterPassFocus bool + invalidate func() } type backgroundActionResult struct { @@ -1069,6 +1071,22 @@ func (u *ui) openRemoteAction() error { if err != nil { return err } + if binding, resolved, ok, err := u.resolvedSelectedVaultRemoteBinding(); err != nil { + return err + } else if ok { + if err := u.state.OpenBoundRemoteVault(binding, key); err != nil { + return err + } + u.remoteBaseURL.SetText(resolved.Profile.BaseURL) + u.remotePath.SetText(resolved.Profile.Path) + u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path) + u.resetPasswordPeek() + u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path) + u.loadSecuritySettingsFromSession() + u.editingEntry = false + u.filter() + return nil + } client := webdav.Client{ BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()), Username: strings.TrimSpace(u.remoteUsername.Text()), @@ -1108,6 +1126,20 @@ func (u *ui) startOpenRemoteAction() { Password: u.remotePassword.Text(), } remotePath := strings.TrimSpace(u.remotePath.Text()) + if _, resolved, ok, err := u.resolvedSelectedVaultRemoteBinding(); err != nil { + u.state.ErrorMessage = u.describeActionError("open remote vault", err) + u.requestMasterPassFocus = true + return + } else if ok { + client = webdav.Client{ + BaseURL: resolved.Profile.BaseURL, + Username: resolved.Credentials.Username, + Password: resolved.Credentials.Password, + } + remotePath = resolved.Profile.Path + u.remoteBaseURL.SetText(resolved.Profile.BaseURL) + u.remotePath.SetText(resolved.Profile.Path) + } u.lastLifecycleAction = "open remote vault" u.runBackgroundAction("open remote vault", func() (func() error, error) { prepared, err := session.PrepareRemoteOpen(client, remotePath, key) @@ -1904,6 +1936,33 @@ func (u *ui) availableRemoteCredentialEntries() []vault.Entry { return entries } +func (u *ui) selectedVaultRemoteBinding() (appstate.RemoteBinding, bool) { + if strings.TrimSpace(u.selectedVaultRemoteProfileID) == "" || strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) == "" { + return appstate.RemoteBinding{}, false + } + return appstate.RemoteBinding{ + LocalVaultPath: strings.TrimSpace(u.vaultPath.Text()), + RemoteProfileID: strings.TrimSpace(u.selectedVaultRemoteProfileID), + CredentialEntryID: strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID), + }, true +} + +func (u *ui) resolvedSelectedVaultRemoteBinding() (appstate.RemoteBinding, appstate.ResolvedRemoteBinding, bool, error) { + binding, ok := u.selectedVaultRemoteBinding() + if !ok { + return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, nil + } + model, err := u.state.Session.Current() + if err != nil { + return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err + } + resolved, err := binding.Resolve(model) + if err != nil { + return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err + } + return binding, resolved, true, nil +} + func (u *ui) noteCurrentRemotePath() { status, ok := u.state.Session.(sessionStatus) if !ok || !status.IsRemote() || status.IsLocked() { diff --git a/main_test.go b/main_test.go index fbf96cb..32fde2c 100644 --- a/main_test.go +++ b/main_test.go @@ -83,6 +83,22 @@ func (s summarySession) HasVault() bool 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 +} + func TestUIFiltersUsingVaultModelPathsAndSearch(t *testing.T) { t.Parallel() @@ -1658,6 +1674,130 @@ func TestUIOpenRemoteReportsTransportFailure(t *testing.T) { } } +func TestUIOpenRemoteActionUsesSelectedVaultBinding(t *testing.T) { + t.Parallel() + + sess := &remoteOpenCaptureSession{ + model: vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "WebDAV Sign-In", + Username: "tjulian", + Password: "token-1", + Path: []string{"Crew", "Internet"}, + }}, + RemoteProfiles: []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }}, + }, + } + + u := newUIWithSession("desktop", sess) + u.masterPassword.SetText("correct horse battery staple") + u.selectedVaultRemoteProfileID = "family-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 != "tjulian" { + t.Fatalf("remoteClient.Username = %q, want tjulian", got) + } + if got := sess.remoteClient.Password; got != "token-1" { + t.Fatalf("remoteClient.Password = %q, want token-1", got) + } + if got := sess.remotePath; got != "files/family/keepass.kdbx" { + t.Fatalf("remotePath = %q, want files/family/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/family/keepass.kdbx" { + t.Fatalf("remotePath editor = %q, want resolved profile path", got) + } +} + +func TestUIStartOpenRemoteActionUsesSelectedVaultBinding(t *testing.T) { + t.Parallel() + + localKey := vault.MasterKey{Password: "correct horse battery staple"} + localModel := vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "WebDAV Sign-In", + Username: "tjulian", + Password: "token-1", + Path: []string{"Crew", "Internet"}, + }}, + RemoteProfiles: []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "", + Path: "files/family/keepass.kdbx", + }}, + } + + remoteModel := vault.Model{ + Entries: []vault.Entry{{ + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }}, + } + + 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.selectedVaultRemoteProfileID = "family-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/family/keepass.kdbx" { + t.Fatalf("remotePath = %q, want selected profile path", got) + } +} + func TestUIRemoteSaveConflictShowsVisibleErrorAndKeepsDirtyState(t *testing.T) { t.Parallel()