Implement local-first remote sync flow

This commit is contained in:
Joe Julian
2026-04-06 21:47:44 -07:00
parent 8433d536f6
commit 332ab58f58
22 changed files with 6428 additions and 918 deletions
+277 -30
View File
@@ -22,7 +22,7 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
Entries: []vault.Entry{
{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", "Home Assistant"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}},
},
},
},
@@ -54,7 +54,7 @@ func TestVisibleEntriesAtParentGroupOnlyShowsDirectEntries(t *testing.T) {
{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", "Home Assistant"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}},
},
},
},
@@ -164,6 +164,71 @@ func TestIssueRotateDisableRevokeAndDeleteAPIToken(t *testing.T) {
}
}
func TestRemoteProfilesReturnsVaultProfiles(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
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",
},
{
ID: "archive-webdav",
Name: "Archive Vault",
Backend: vault.RemoteBackendWebDAV,
BaseURL: "https://dav.example.invalid/remote.php/dav",
Path: "files/family/archive.kdbx",
},
},
},
},
}
got, err := state.RemoteProfiles()
if err != nil {
t.Fatalf("RemoteProfiles() error = %v", err)
}
if len(got) != 2 {
t.Fatalf("len(RemoteProfiles()) = %d, want 2", len(got))
}
if got[0].ID != "archive-webdav" || got[1].ID != "family-webdav" {
t.Fatalf("RemoteProfiles() = %#v, want sorted by name/id", got)
}
}
func TestRemoteCredentialEntriesReturnsSortedVaultEntries(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: 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"}},
{ID: "cred-3", Title: "Mint Sign-In", Username: "frankcatton", Path: []string{"Crew", "Safe House"}},
},
},
},
}
got, err := state.RemoteCredentialEntries()
if err != nil {
t.Fatalf("RemoteCredentialEntries() error = %v", err)
}
if len(got) != 3 {
t.Fatalf("len(RemoteCredentialEntries()) = %d, want 3", len(got))
}
if got[0].ID != "cred-1" || got[1].ID != "cred-3" || got[2].ID != "cred-2" {
t.Fatalf("RemoteCredentialEntries() = %#v, want entries sorted by title", got)
}
}
func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
t.Parallel()
@@ -173,7 +238,7 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Security Office"}},
},
},
},
@@ -187,7 +252,7 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
}
if len(got) != 1 || got[0].Title != "Surveillance Console" {
t.Fatalf("VisibleEntries() = %#v, want Home Assistant search match", got)
t.Fatalf("VisibleEntries() = %#v, want Security Office search match", got)
}
}
@@ -200,7 +265,7 @@ func TestVisibleEntriesReturnsDescendantsAfterClearingSearch(t *testing.T) {
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Security Office"}},
},
},
},
@@ -350,7 +415,7 @@ func TestVisibleEntriesUsesGlobalSearchWithinRecycleBin(t *testing.T) {
model: vault.Model{
RecycleBin: []vault.Entry{
{ID: "deleted-1", Title: "Deleted Bellagio", Path: []string{"Root", "Internet"}},
{ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}},
{ID: "deleted-2", Title: "Deleted Vault Vent", URL: "https://climate.example.com", Path: []string{"Root", "Safe House"}},
},
},
},
@@ -419,7 +484,7 @@ func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
model: vault.Model{
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}},
{ID: "alma", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}},
},
},
@@ -432,8 +497,8 @@ func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
t.Fatalf("ChildGroups() error = %v", err)
}
if !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got)
if !slices.Equal(got, []string{"Internet", "Security Office"}) {
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got)
}
}
@@ -603,7 +668,7 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
Password: "bellagio-pass-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
}
@@ -619,7 +684,7 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].Password != "token-1" {
if len(got) != 1 || got[0].Password != "bellagio-pass-1" {
t.Fatalf("VisibleEntries() = %#v, want persisted vault-console entry", got)
}
@@ -964,6 +1029,185 @@ func TestOpenRemoteVaultResetsSelectionPathAndDirtyState(t *testing.T) {
}
}
func TestOpenBoundRemoteVaultResolvesClientFromVaultBinding(t *testing.T) {
t.Parallel()
sess := &lifecycleStubSession{
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: "family-webdav",
Name: "Family Vault",
Backend: vault.RemoteBackendWebDAV,
BaseURL: "https://dav.example.invalid/remote.php/dav",
Path: "files/family/keepass.kdbx",
},
},
},
}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console",
Dirty: true,
}
err := state.OpenBoundRemoteVault(RemoteBinding{
LocalVaultPath: "/tmp/family.kdbx",
RemoteProfileID: "family-webdav",
CredentialEntryID: "remote-creds-1",
SyncMode: SyncModeAutomaticOnOpenSave,
}, vault.MasterKey{Password: "correct horse battery staple"})
if err != nil {
t.Fatalf("OpenBoundRemoteVault() error = %v", err)
}
if got := sess.remoteClient.BaseURL; got != "https://dav.example.invalid/remote.php/dav" {
t.Fatalf("remote client base URL = %q, want remote.php/dav URL", got)
}
if got := sess.remoteClient.Username; got != "linuscaldwell" {
t.Fatalf("remote client username = %q, want linuscaldwell", got)
}
if got := sess.remoteClient.Password; got != "bellagio-pass-1" {
t.Fatalf("remote client password = %q, want bellagio-pass-1", got)
}
if got := sess.remotePath; got != "files/family/keepass.kdbx" {
t.Fatalf("remotePath = %q, want files/family/keepass.kdbx", got)
}
if len(state.CurrentPath) != 0 {
t.Fatalf("CurrentPath = %v, want empty", state.CurrentPath)
}
if state.SelectedEntryID != "" {
t.Fatalf("SelectedEntryID = %q, want empty", state.SelectedEntryID)
}
if state.Dirty {
t.Fatal("Dirty = true, want false after bound remote open")
}
}
func TestOpenBoundRemoteVaultReturnsResolutionErrors(t *testing.T) {
t.Parallel()
sess := &lifecycleStubSession{model: vault.Model{}}
state := State{Session: sess}
err := state.OpenBoundRemoteVault(RemoteBinding{
LocalVaultPath: "/tmp/family.kdbx",
RemoteProfileID: "missing-profile",
CredentialEntryID: "remote-creds-1",
}, vault.MasterKey{Password: "correct horse battery staple"})
if !errors.Is(err, vault.ErrRemoteProfileNotFound) {
t.Fatalf("OpenBoundRemoteVault() error = %v, want ErrRemoteProfileNotFound", err)
}
}
func TestConfigureRemoteBindingPersistsIntoCurrentVaultAndMarksDirty(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{model: vault.Model{}}
state := State{Session: sess}
binding, err := state.ConfigureRemoteBinding(RemoteBindingInput{
LocalVaultPath: "/tmp/family.kdbx",
RemoteProfileID: "family-webdav",
RemoteProfileName: "Family Vault",
BaseURL: "https://dav.example.invalid/remote.php/dav",
RemotePath: "files/family/keepass.kdbx",
CredentialEntryID: "remote-creds-1",
CredentialTitle: "Bellagio WebDAV Sign-In",
Username: "linuscaldwell",
Password: "bellagio-pass-1",
CredentialPath: []string{"Crew", "Internet"},
SyncMode: SyncModeAutomaticOnOpenSave,
})
if err != nil {
t.Fatalf("ConfigureRemoteBinding() error = %v", err)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after ConfigureRemoteBinding")
}
if got := binding.RemoteProfileID; got != "family-webdav" {
t.Fatalf("binding.RemoteProfileID = %q, want family-webdav", got)
}
if got := len(sess.model.RemoteProfiles); got != 1 {
t.Fatalf("len(RemoteProfiles) = %d, want 1", got)
}
credentials, err := sess.model.EntryByID("remote-creds-1")
if err != nil {
t.Fatalf("EntryByID(remote-creds-1) error = %v", err)
}
if credentials.Username != "linuscaldwell" || credentials.Password != "bellagio-pass-1" {
t.Fatalf("stored credential entry = %#v, want linuscaldwell/bellagio-pass-1", credentials)
}
}
func TestConfigureRemoteBindingRequiresMutableSession(t *testing.T) {
t.Parallel()
state := State{Session: stubSession{model: vault.Model{}}}
_, err := state.ConfigureRemoteBinding(RemoteBindingInput{
LocalVaultPath: "/tmp/family.kdbx",
RemoteProfileID: "family-webdav",
BaseURL: "https://dav.example.invalid/remote.php/dav",
RemotePath: "files/family/keepass.kdbx",
CredentialEntryID: "remote-creds-1",
Password: "bellagio-pass-1",
})
if err == nil {
t.Fatal("ConfigureRemoteBinding() error = nil, want mutability error")
}
}
func TestRemoveRemoteBindingRemovesVaultDataAndMarksDirty(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{model: vault.Model{
Entries: []vault.Entry{{
ID: "remote-creds-1",
Title: "Bellagio WebDAV Sign-In",
Username: "linuscaldwell",
Password: "bellagio-pass-1",
}},
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",
}},
}}
state := State{Session: sess}
err := state.RemoveRemoteBinding(RemoteBinding{
LocalVaultPath: "/tmp/family.kdbx",
RemoteProfileID: "family-webdav",
CredentialEntryID: "remote-creds-1",
})
if err != nil {
t.Fatalf("RemoveRemoteBinding() error = %v", err)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after RemoveRemoteBinding")
}
if got := len(sess.model.RemoteProfiles); got != 0 {
t.Fatalf("len(RemoteProfiles) = %d, want 0", got)
}
if _, err := sess.model.EntryByID("remote-creds-1"); !errors.Is(err, vault.ErrEntryNotFound) {
t.Fatalf("EntryByID(remote-creds-1) error = %v, want ErrEntryNotFound", err)
}
}
func TestLockClearsSelectionAndMakesVaultUnavailable(t *testing.T) {
t.Parallel()
@@ -1149,10 +1393,10 @@ func TestNavigateToPathReplacesPathAndClearsSelection(t *testing.T) {
SelectedEntryID: "vault-console",
}
state.NavigateToPath([]string{"Root", "Home Assistant"})
state.NavigateToPath([]string{"Root", "Security Office"})
if !slices.Equal(state.CurrentPath, []string{"Root", "Home Assistant"}) {
t.Fatalf("CurrentPath = %v, want [Root Home Assistant]", state.CurrentPath)
if !slices.Equal(state.CurrentPath, []string{"Root", "Security Office"}) {
t.Fatalf("CurrentPath = %v, want [Root Security Office]", state.CurrentPath)
}
if got := state.SelectedEntryID; got != "" {
t.Fatalf("SelectedEntryID = %q, want empty", got)
@@ -1185,8 +1429,8 @@ func TestDeleteCurrentGroupMovesToParentAndMarksDirty(t *testing.T) {
if err != nil {
t.Fatalf("ChildGroups() error = %v", err)
}
if !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got)
if !slices.Equal(got, []string{"Internet", "Security Office"}) {
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got)
}
}
@@ -1208,8 +1452,8 @@ func TestCreateGroupPersistsGroupAndMarksDirty(t *testing.T) {
t.Fatalf("ChildGroups() error = %v", err)
}
if !slices.Equal(got, []string{"Finance", "Home Assistant", "Internet"}) {
t.Fatalf("ChildGroups() = %v, want Finance, Home Assistant, Internet", got)
if !slices.Equal(got, []string{"Finance", "Internet", "Security Office"}) {
t.Fatalf("ChildGroups() = %v, want Finance, Internet, Security Office", got)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after CreateGroup")
@@ -1282,8 +1526,8 @@ func TestDeleteCurrentGroupRemovesItNavigatesToParentAndMarksDirty(t *testing.T)
t.Fatalf("ChildGroups() error = %v", err)
}
if !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got)
if !slices.Equal(got, []string{"Internet", "Security Office"}) {
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after DeleteCurrentGroup")
@@ -1300,11 +1544,11 @@ func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) {
SelectedEntryID: "bellagio",
}
if err := state.MoveSelectedEntry([]string{"Root", "Home Assistant"}); err != nil {
if err := state.MoveSelectedEntry([]string{"Root", "Security Office"}); err != nil {
t.Fatalf("MoveSelectedEntry() error = %v", err)
}
state.NavigateToPath([]string{"Root", "Home Assistant"})
state.NavigateToPath([]string{"Root", "Security Office"})
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
@@ -1512,7 +1756,7 @@ func testVaultModel() vault.Model {
return vault.Model{
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"Root", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Root", "Home Assistant"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Root", "Security Office"}},
},
}
}
@@ -1553,15 +1797,17 @@ func (s *saveableStubSession) Save() error {
}
type lifecycleStubSession struct {
createCalls int
openPath string
saveAsPath string
remotePath string
changedKey vault.MasterKey
createCalls int
model vault.Model
openPath string
saveAsPath string
remoteClient webdav.Client
remotePath string
changedKey vault.MasterKey
}
func (s *lifecycleStubSession) Current() (vault.Model, error) {
return vault.Model{}, nil
return s.model, nil
}
func (s *lifecycleStubSession) Create(_ vault.Model, _ vault.MasterKey) error {
@@ -1579,7 +1825,8 @@ func (s *lifecycleStubSession) SaveAs(path string) error {
return nil
}
func (s *lifecycleStubSession) OpenRemote(_ webdav.Client, path string, _ vault.MasterKey) error {
func (s *lifecycleStubSession) OpenRemote(client webdav.Client, path string, _ vault.MasterKey) error {
s.remoteClient = client
s.remotePath = path
return nil
}