Implement local-first remote sync policy
This commit is contained in:
+218
-4
@@ -2188,6 +2188,14 @@ func TestUIOpenVaultActionSelectsSoleSavedRemoteBinding(t *testing.T) {
|
||||
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/family/keepass.kdbx",
|
||||
LocalVaultPath: path,
|
||||
RemoteProfileID: "family-webdav",
|
||||
CredentialEntryID: "remote-creds-1",
|
||||
SyncMode: string(appstate.SyncModeManual),
|
||||
}}
|
||||
u.vaultPath.SetText(path)
|
||||
u.masterPassword.SetText(key.Password)
|
||||
u.selectedVaultRemoteProfileID = "stale-profile"
|
||||
@@ -2211,6 +2219,9 @@ func TestUIOpenVaultActionSelectsSoleSavedRemoteBinding(t *testing.T) {
|
||||
if got := u.remotePath.Text(); got != "files/family/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) {
|
||||
@@ -2271,6 +2282,180 @@ func TestUIStartOpenVaultActionSelectsSoleSavedRemoteBinding(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIOpenVaultActionAutomaticallySynchronizesFromRemoteBinding(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key := vault.MasterKey{Password: "correct horse battery staple"}
|
||||
path := filepath.Join(t.TempDir(), "family.kdbx")
|
||||
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: "https://stale.example.invalid/remote.php/dav",
|
||||
Path: "files/family/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 != "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(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/family/keepass.kdbx",
|
||||
LocalVaultPath: path,
|
||||
RemoteProfileID: "family-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 TestUISaveActionAutomaticallySynchronizesToRemoteBinding(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key := vault.MasterKey{Password: "correct horse battery staple"}
|
||||
path := filepath.Join(t.TempDir(), "family.kdbx")
|
||||
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: "https://stale.example.invalid/remote.php/dav",
|
||||
Path: "files/family/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 != "tjulian" || password != "token-1" {
|
||||
t.Fatalf("BasicAuth() = (%q, %q, %t), want (tjulian, token-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/family/keepass.kdbx",
|
||||
LocalVaultPath: path,
|
||||
RemoteProfileID: "family-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 TestUIRemoteSaveConflictShowsVisibleErrorAndKeepsDirtyState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -4510,6 +4695,7 @@ func TestUIRecentRemoteConnectionsPersistVaultBindingMetadata(t *testing.T) {
|
||||
first.vaultPath.SetText("/vaults/family.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{})
|
||||
@@ -4530,6 +4716,9 @@ func TestUIRecentRemoteConnectionsPersistVaultBindingMetadata(t *testing.T) {
|
||||
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) {
|
||||
@@ -4611,6 +4800,7 @@ func TestUIApplyRecentRemoteRecordRestoresVaultBindingSelection(t *testing.T) {
|
||||
LocalVaultPath: "/vaults/family.kdbx",
|
||||
RemoteProfileID: "remote-profile-1",
|
||||
CredentialEntryID: "remote-creds-1",
|
||||
SyncMode: string(appstate.SyncModeAutomaticOnOpenSave),
|
||||
})
|
||||
|
||||
if got := u.vaultPath.Text(); got != "/vaults/family.kdbx" {
|
||||
@@ -4622,6 +4812,9 @@ func TestUIApplyRecentRemoteRecordRestoresVaultBindingSelection(t *testing.T) {
|
||||
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) {
|
||||
@@ -5829,13 +6022,13 @@ func TestUIRemoteOpenButtonLabelOffersRetryAfterFailure(t *testing.T) {
|
||||
u := newUIWithSession("desktop", &session.Manager{})
|
||||
u.lifecycleMode = "remote"
|
||||
|
||||
if got := u.remoteOpenButtonLabel(); got != "Open Remote Vault" {
|
||||
t.Fatalf("remoteOpenButtonLabel() = %q, want %q", got, "Open Remote Vault")
|
||||
if got := u.remoteOpenButtonLabel(); got != "Open And Cache Vault" {
|
||||
t.Fatalf("remoteOpenButtonLabel() = %q, want %q", got, "Open And Cache Vault")
|
||||
}
|
||||
|
||||
u.state.ErrorMessage = "open remote vault failed: dial tcp timeout"
|
||||
if got := u.remoteOpenButtonLabel(); got != "Retry Remote Vault" {
|
||||
t.Fatalf("remoteOpenButtonLabel() after error = %q, want %q", got, "Retry Remote Vault")
|
||||
if got := u.remoteOpenButtonLabel(); got != "Retry Cached Vault Setup" {
|
||||
t.Fatalf("remoteOpenButtonLabel() after error = %q, want %q", got, "Retry Cached Vault Setup")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5857,6 +6050,27 @@ func TestUIRemoteLifecycleMessageUsesLocalCacheLanguageForBoundRemote(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user