From fc921ca30f5ff21a5b74df9cfbae0140f2a6c75b Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Mon, 6 Apr 2026 16:38:15 -0700 Subject: [PATCH] Materialize local cache on first remote open --- main.go | 42 +++++++++++ main_test.go | 197 +++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 233 insertions(+), 6 deletions(-) diff --git a/main.go b/main.go index 36761cb..65f256e 100644 --- a/main.go +++ b/main.go @@ -1104,6 +1104,9 @@ func (u *ui) openRemoteAction() error { if err := u.state.OpenRemoteVault(client, strings.TrimSpace(u.remotePath.Text()), key); err != nil { return err } + if err := u.materializeCurrentRemoteCache(); err != nil { + return err + } u.noteRecentRemote( strings.TrimSpace(u.remoteBaseURL.Text()), strings.TrimSpace(u.remotePath.Text()), @@ -1192,6 +1195,9 @@ func (u *ui) startOpenRemoteAction() { } return func() error { manager.ApplyPreparedRemoteOpen(prepared) + if err := u.materializeCurrentRemoteCache(); err != nil { + return err + } u.noteRecentRemote( strings.TrimSpace(u.remoteBaseURL.Text()), remotePath, @@ -2097,6 +2103,42 @@ func (u *ui) saveCurrentRemoteBindingAction() error { return nil } +func (u *ui) materializeCurrentRemoteCache() error { + cachePath := strings.TrimSpace(u.vaultPath.Text()) + if cachePath == "" { + cachePath = u.saveAsTargetPath() + } + if cachePath == "" { + return nil + } + u.vaultPath.SetText(cachePath) + if err := u.state.SaveAs(cachePath); err != nil { + return err + } + u.noteRecentVault(cachePath) + + username := strings.TrimSpace(u.remoteUsername.Text()) + password := u.remotePassword.Text() + if username == "" && password == "" { + return nil + } + + input, err := u.currentRemoteBindingInput() + if err != nil { + return err + } + binding, err := u.state.ConfigureRemoteBinding(input) + if err != nil { + return err + } + if err := u.state.SaveAs(cachePath); err != nil { + return err + } + u.selectedVaultRemoteProfileID = binding.RemoteProfileID + u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID + return nil +} + func (u *ui) bootstrapSelectedVaultRemoteBinding(key vault.MasterKey) (appstate.RemoteBinding, appstate.ResolvedRemoteBinding, bool, error) { if u.hasOpenVault() { return u.resolvedSelectedVaultRemoteBinding() diff --git a/main_test.go b/main_test.go index b32fed7..37a5adf 100644 --- a/main_test.go +++ b/main_test.go @@ -262,7 +262,12 @@ func TestUIClearingSearchResetsToCurrentSectionListing(t *testing.T) { func TestUIRunBackgroundActionIgnoresDuplicateWhileLoading(t *testing.T) { t.Parallel() - u := newUIWithSession("desktop", &session.Manager{}) + 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 @@ -298,7 +303,12 @@ func TestUIRunBackgroundActionIgnoresDuplicateWhileLoading(t *testing.T) { func TestUICancelLifecycleBusyStateIgnoresLateResultAndKeepsRetryAvailable(t *testing.T) { t.Parallel() - u := newUIWithSession("desktop", &session.Manager{}) + 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" @@ -1191,7 +1201,12 @@ func TestUISaveSecuritySettingsUpdatesExistingVault(t *testing.T) { t.Parallel() manager := &session.Manager{} - u := newUIWithSession("desktop", 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) @@ -1263,7 +1278,12 @@ func TestUISaveSettingsPersistsUIPreferences(t *testing.T) { func TestUILockAndUnlockClearMasterPasswordField(t *testing.T) { t.Parallel() - u := newUIWithSession("desktop", &session.Manager{}) + 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) @@ -1621,7 +1641,12 @@ func TestUIStartOpenRemoteActionAppliesResultOnMainThread(t *testing.T) { defer server.Close() manager := &session.Manager{} - u := newUIWithSession("desktop", 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") @@ -1659,7 +1684,12 @@ func TestUIOpenRemoteReportsTransportFailure(t *testing.T) { url := server.URL server.Close() - u := newUIWithSession("desktop", &session.Manager{}) + 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") @@ -5331,6 +5361,161 @@ func TestUIOpenRemoteVaultRestoresLastOpenedGroupForThatConnection(t *testing.T) } } +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 != "tjulian" || password != "token-1" { + t.Fatalf("BasicAuth() = (%q, %q, %t), want (tjulian, token-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("tjulian") + u.remotePassword.SetText("token-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 != "tjulian" || entry.Password != "token-1" { + t.Fatalf("credential entry = %#v, want tjulian/token-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 != "tjulian" || password != "token-1" { + t.Fatalf("BasicAuth() = (%q, %q, %t), want (tjulian, token-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("tjulian") + u.remotePassword.SetText("token-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()