package appstate import ( "errors" "slices" "testing" "time" "git.julianfamily.org/keepassgo/internal/apiapproval" "git.julianfamily.org/keepassgo/internal/apiaudit" "git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/internal/session" "git.julianfamily.org/keepassgo/internal/vault" "git.julianfamily.org/keepassgo/internal/webdav" ) func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) { t.Parallel() state := State{ Session: stubSession{ model: vault.Model{ 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", "Security Office"}}, }, }, }, CurrentPath: []string{"Root", "Crew", "Internet"}, } got, err := state.VisibleEntries() if err != nil { t.Fatalf("VisibleEntries() error = %v", err) } titles := make([]string, 0, len(got)) for _, entry := range got { titles = append(titles, entry.Title) } if !slices.Equal(titles, []string{"Bellagio", "Vault Console"}) { t.Fatalf("visible titles = %v, want [Bellagio Vault Console]", titles) } } func TestVisibleEntriesAtParentGroupOnlyShowsDirectEntries(t *testing.T) { t.Parallel() state := State{ Session: stubSession{ model: 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"}}, }, }, }, CurrentPath: []string{"Crew"}, } got, err := state.VisibleEntries() if err != nil { t.Fatalf("VisibleEntries() error = %v", err) } titles := make([]string, 0, len(got)) for _, entry := range got { titles = append(titles, entry.Title) } if !slices.Equal(titles, []string{"Crew Note"}) { t.Fatalf("visible titles = %v, want only direct entries from Crew", titles) } } func TestPendingApprovalsReturnsManagerRequests(t *testing.T) { t.Parallel() state := State{ Approvals: &stubApprovalManager{ pending: []apiapproval.Request{ {ID: "approval-1", TokenName: "CLI", Operation: apitokens.OperationListEntries}, }, }, } got := state.PendingApprovals() if len(got) != 1 || got[0].ID != "approval-1" { t.Fatalf("PendingApprovals() = %#v, want approval-1", got) } } func TestResolveApprovalDelegatesToManager(t *testing.T) { t.Parallel() manager := &stubApprovalManager{} state := State{Approvals: manager} if err := state.ResolveApproval("approval-1", apiapproval.OutcomeAllowPermanent); err != nil { t.Fatalf("ResolveApproval() error = %v", err) } if manager.lastID != "approval-1" || manager.lastOutcome != apiapproval.OutcomeAllowPermanent { t.Fatalf("ResolveApproval() delegated (%q, %q), want (approval-1, allow-permanent)", manager.lastID, manager.lastOutcome) } } func TestIssueRotateDisableRevokeAndDeleteAPIToken(t *testing.T) { t.Parallel() session := &mutableStubSession{model: vault.Model{}} auditLog := apiaudit.New(10) state := State{Session: session, AuditLog: auditLog} now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC) expiresAt := now.Add(24 * time.Hour) issued, secret, err := state.IssueAPIToken("CLI", "grpc-cli", &expiresAt, now) if err != nil { t.Fatalf("IssueAPIToken() error = %v", err) } if issued.ID == "" || secret == "" { t.Fatalf("IssueAPIToken() = %#v, %q, want non-empty id and secret", issued, secret) } tokens, err := state.APITokens() if err != nil { t.Fatalf("APITokens() error = %v", err) } if len(tokens) != 1 || tokens[0].ID != issued.ID { t.Fatalf("APITokens() = %#v, want issued token", tokens) } rotated, rotatedSecret, err := state.RotateAPIToken(issued.ID, now.Add(time.Hour)) if err != nil { t.Fatalf("RotateAPIToken() error = %v", err) } if rotated.ID != issued.ID || rotatedSecret == "" || rotatedSecret == secret { t.Fatalf("RotateAPIToken() = %#v, %q, want same id and new secret", rotated, rotatedSecret) } if err := state.DisableAPIToken(issued.ID); err != nil { t.Fatalf("DisableAPIToken() error = %v", err) } if err := state.RevokeAPIToken(issued.ID, now.Add(2*time.Hour)); err != nil { t.Fatalf("RevokeAPIToken() error = %v", err) } tokens, err = state.APITokens() if err != nil { t.Fatalf("APITokens() after revoke error = %v", err) } if len(tokens) != 1 || !tokens[0].Disabled || tokens[0].RevokedAt == nil { t.Fatalf("APITokens() after revoke = %#v, want disabled revoked token", tokens) } if err := state.DeleteAPIToken(issued.ID); err != nil { t.Fatalf("DeleteAPIToken() error = %v", err) } tokens, err = state.APITokens() if err != nil { t.Fatalf("APITokens() after delete error = %v", err) } if len(tokens) != 0 { t.Fatalf("APITokens() after delete = %#v, want empty", tokens) } events := auditLog.Events() if len(events) != 5 { t.Fatalf("len(AuditLog.Events()) = %d, want 5", len(events)) } if events[0].Type != apiaudit.EventTokenDeleted || events[1].Type != apiaudit.EventTokenRevoked || events[2].Type != apiaudit.EventTokenDisabled || events[3].Type != apiaudit.EventTokenRotated || events[4].Type != apiaudit.EventTokenIssued { t.Fatalf("AuditLog.Events() types = %#v, want deleted/revoked/disabled/rotated/issued", events) } if events[0].TokenID != issued.ID || events[0].Resource.EntryID != issued.ID { t.Fatalf("delete audit event = %#v, want token/resource id %q", events[0], issued.ID) } if events[4].TokenName != "CLI" || events[4].ClientName != "grpc-cli" { t.Fatalf("issued audit event = %#v, want CLI/grpc-cli metadata", events[4]) } } func TestIssueAPITokenAutoSavesWhenSessionSupportsSaving(t *testing.T) { t.Parallel() session := &mutableSaveableStubSession{model: vault.Model{}, hasSaveTarget: true} state := State{Session: session} now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC) if _, _, err := state.IssueAPIToken("CLI", "grpc-cli", nil, now); err != nil { t.Fatalf("IssueAPIToken() error = %v", err) } if session.saveCalls != 1 { t.Fatalf("saveCalls = %d, want 1", session.saveCalls) } if state.Dirty { t.Fatal("Dirty = true, want false after autosave") } } func TestIssueAPITokenDoesNotAutoSaveWithoutSaveTarget(t *testing.T) { t.Parallel() session := &mutableSaveableStubSession{model: vault.Model{}} state := State{Session: session} now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC) if _, _, err := state.IssueAPIToken("CLI", "grpc-cli", nil, now); err != nil { t.Fatalf("IssueAPIToken() error = %v", err) } if session.saveCalls != 0 { t.Fatalf("saveCalls = %d, want 0", session.saveCalls) } if !state.Dirty { t.Fatal("Dirty = false, want true when no save target exists") } } func TestIssueAPITokenDoesNotAutoSaveForRemoteSession(t *testing.T) { t.Parallel() session := &mutableSaveableStubSession{ model: vault.Model{}, hasSaveTarget: true, remote: true, } state := State{Session: session} now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC) if _, _, err := state.IssueAPIToken("CLI", "grpc-cli", nil, now); err != nil { t.Fatalf("IssueAPIToken() error = %v", err) } if session.saveCalls != 0 { t.Fatalf("saveCalls = %d, want 0", session.saveCalls) } if !state.Dirty { t.Fatal("Dirty = false, want true for remote session") } } func TestIssueAPITokenAutoSavesForRemoteSessionWhenEnabled(t *testing.T) { t.Parallel() session := &mutableSaveableStubSession{ model: vault.Model{}, hasSaveTarget: true, remote: true, } state := State{ Session: session, AutoSaveRemote: true, } now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC) if _, _, err := state.IssueAPIToken("CLI", "grpc-cli", nil, now); err != nil { t.Fatalf("IssueAPIToken() error = %v", err) } if session.saveCalls != 1 { t.Fatalf("saveCalls = %d, want 1", session.saveCalls) } if state.Dirty { t.Fatal("Dirty = true, want false after remote autosave") } } func TestRemoteProfilesReturnsVaultProfiles(t *testing.T) { t.Parallel() state := State{ Session: stubSession{ model: 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, 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 != "bellagio-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() state := State{ Session: stubSession{ model: vault.Model{ 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", "Security Office"}}, }, }, }, CurrentPath: []string{"Crew", "Internet"}, SearchQuery: "surveillance", } got, err := state.VisibleEntries() if err != nil { t.Fatalf("VisibleEntries() error = %v", err) } if len(got) != 1 || got[0].Title != "Surveillance Console" { t.Fatalf("VisibleEntries() = %#v, want Security Office search match", got) } } func TestVisibleEntriesReturnsDescendantsAfterClearingSearch(t *testing.T) { t.Parallel() state := State{ Session: stubSession{ model: vault.Model{ 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", "Security Office"}}, }, }, }, CurrentPath: []string{"Crew"}, SearchQuery: "missing", } got, err := state.VisibleEntries() if err != nil { t.Fatalf("VisibleEntries() with search error = %v", err) } if len(got) != 0 { t.Fatalf("VisibleEntries() with missing search = %#v, want empty", got) } state.SearchQuery = "" got, err = state.VisibleEntries() if err != nil { t.Fatalf("VisibleEntries() after clearing search error = %v", err) } if len(got) != 0 { t.Fatalf("len(VisibleEntries()) after clearing search = %d, want 0 direct entries at Crew", len(got)) } } func TestVisibleEntriesUsesTemplateSection(t *testing.T) { t.Parallel() state := State{ Session: stubSession{ model: vault.Model{ Templates: []vault.Entry{ {ID: "tpl-1", Title: "Website Login", Path: []string{"Templates", "Web"}}, {ID: "tpl-2", Title: "SSH Login", Path: []string{"Templates", "Infra"}}, }, }, }, Section: SectionTemplates, } got, err := state.VisibleEntries() if err != nil { t.Fatalf("VisibleEntries() error = %v", err) } if len(got) != 2 { t.Fatalf("len(VisibleEntries()) = %d, want 2 templates", len(got)) } } func TestVisibleEntriesUsesRecycleBinSection(t *testing.T) { t.Parallel() state := State{ Session: stubSession{ model: vault.Model{ RecycleBin: []vault.Entry{ {ID: "entry-1", Title: "Deleted Entry", Path: []string{"Root", "Internet"}}, }, }, }, Section: SectionRecycleBin, } got, err := state.VisibleEntries() if err != nil { t.Fatalf("VisibleEntries() error = %v", err) } if len(got) != 1 || got[0].ID != "entry-1" { t.Fatalf("VisibleEntries() = %#v, want recycle-bin entry", got) } } func TestVisibleEntriesUsesGlobalSearchWithinTemplateSection(t *testing.T) { t.Parallel() state := State{ Session: stubSession{ model: vault.Model{ 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"}}, }, }, }, Section: SectionTemplates, CurrentPath: []string{"Templates", "Web"}, SearchQuery: "infra", } got, err := state.VisibleEntries() if err != nil { t.Fatalf("VisibleEntries() error = %v", err) } if len(got) != 1 || got[0].ID != "tpl-2" { t.Fatalf("VisibleEntries() = %#v, want global template search result tpl-2", got) } } func TestVisibleEntriesResetToCurrentTemplatePathAfterClearingSearch(t *testing.T) { t.Parallel() state := State{ Session: stubSession{ model: 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"}}, }, }, }, Section: SectionTemplates, CurrentPath: []string{"Templates", "Web"}, SearchQuery: "ssh", } got, err := state.VisibleEntries() if err != nil { t.Fatalf("VisibleEntries() with search error = %v", err) } if len(got) != 1 || got[0].ID != "tpl-3" { t.Fatalf("VisibleEntries() with search = %#v, want tpl-3", got) } state.SearchQuery = "" got, err = state.VisibleEntries() if err != nil { t.Fatalf("VisibleEntries() after clearing search error = %v", err) } if len(got) != 2 { t.Fatalf("len(VisibleEntries()) after clearing search = %d, want 2", len(got)) } if titles := []string{got[0].Title, got[1].Title}; !slices.Equal(titles, []string{"Email Login", "Website Login"}) { t.Fatalf("VisibleEntries() after clearing search titles = %v, want [Email Login Website Login]", titles) } } func TestVisibleEntriesUsesGlobalSearchWithinRecycleBin(t *testing.T) { t.Parallel() state := State{ Session: stubSession{ model: vault.Model{ RecycleBin: []vault.Entry{ {ID: "deleted-1", Title: "Deleted Bellagio", Path: []string{"Root", "Internet"}}, {ID: "deleted-2", Title: "Deleted Vault Vent", URL: "https://climate.example.com", Path: []string{"Root", "Safe House"}}, }, }, }, Section: SectionRecycleBin, CurrentPath: []string{"Root", "Internet"}, SearchQuery: "climate", } got, err := state.VisibleEntries() if err != nil { t.Fatalf("VisibleEntries() error = %v", err) } if len(got) != 1 || got[0].ID != "deleted-2" { t.Fatalf("VisibleEntries() = %#v, want global recycle-bin search result deleted-2", got) } } func TestSearchPathContextIncludesSectionRoots(t *testing.T) { t.Parallel() tests := []struct { name string section Section entry vault.Entry want string }{ { name: "entries use direct path", section: SectionEntries, entry: vault.Entry{Path: []string{"Root", "Internet"}}, want: "Root / Internet", }, { name: "templates retain templates root", section: SectionTemplates, entry: vault.Entry{Path: []string{"Templates", "Web"}}, want: "Templates / Web", }, { name: "recycle bin prefixes root label", section: SectionRecycleBin, entry: vault.Entry{Path: []string{"Root", "Internet"}}, want: "Recycle Bin / Root / Internet", }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() state := State{Section: tt.section} if got := state.SearchPathContext(tt.entry); got != tt.want { t.Fatalf("SearchPathContext(%v) = %q, want %q", tt.entry.Path, got, tt.want) } }) } } func TestVisibleEntriesUseLogicalVaultRootForPhysicalKeepassModel(t *testing.T) { t.Parallel() state := State{ Session: stubSession{ model: vault.Model{ Entries: []vault.Entry{ {ID: "bellagio", Title: "Bellagio", Path: []string{"keepass", "Crew", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"keepass", "Crew", "Internet"}}, {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"keepass", "Crew", "Security Office"}}, }, Groups: [][]string{ {"keepass"}, {"keepass", "Crew"}, {"keepass", "Crew", "Internet"}, {"keepass", "Crew", "Security Office"}, }, }, }, CurrentPath: []string{"Crew", "Internet"}, } got, err := state.VisibleEntries() if err != nil { t.Fatalf("VisibleEntries() error = %v", err) } titles := make([]string, 0, len(got)) for _, entry := range got { titles = append(titles, entry.Title) } if !slices.Equal(titles, []string{"Bellagio", "Vault Console"}) { t.Fatalf("VisibleEntries() titles = %v, want [Bellagio Vault Console]", titles) } if !slices.Equal(got[0].Path, []string{"Root", "Crew", "Internet"}) { t.Fatalf("VisibleEntries()[0].Path = %v, want [Root Crew Internet]", got[0].Path) } } func TestChildGroupsUseLogicalVaultRootForPhysicalKeepassModel(t *testing.T) { t.Parallel() state := State{ Session: stubSession{ model: vault.Model{ Entries: []vault.Entry{ {ID: "bellagio", Title: "Bellagio", Path: []string{"keepass", "Crew", "Internet"}}, {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"keepass", "Crew", "Security Office"}}, }, Groups: [][]string{ {"keepass"}, {"keepass", "Crew"}, {"keepass", "Crew", "Internet"}, {"keepass", "Crew", "Security Office"}, }, }, }, } got, err := state.ChildGroups() if err != nil { t.Fatalf("ChildGroups() error = %v", err) } if !slices.Equal(got, []string{"Crew"}) { t.Fatalf("ChildGroups() = %v, want [Crew]", got) } } func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) { t.Parallel() state := State{ Session: stubSession{ model: vault.Model{ Entries: []vault.Entry{ {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}}, {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}}, {ID: "alma", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}}, }, }, }, CurrentPath: []string{"Crew"}, } got, err := state.ChildGroups() if err != nil { t.Fatalf("ChildGroups() error = %v", err) } if !slices.Equal(got, []string{"Internet", "Security Office"}) { t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got) } } func TestChildGroupsUsesTemplateSectionPaths(t *testing.T) { t.Parallel() state := State{ Session: stubSession{ model: vault.Model{ Templates: []vault.Entry{ {ID: "tpl-1", Title: "Website Login", Path: []string{"Templates", "Web"}}, {ID: "tpl-2", Title: "SSH Login", Path: []string{"Templates", "Infra"}}, }, }, }, Section: SectionTemplates, CurrentPath: []string{"Templates"}, } got, err := state.ChildGroups() if err != nil { t.Fatalf("ChildGroups() error = %v", err) } if !slices.Equal(got, []string{"Infra", "Web"}) { t.Fatalf("ChildGroups() = %v, want [Infra Web]", got) } } func TestSelectVisibleEntryAndToggleSelection(t *testing.T) { t.Parallel() state := State{ Session: stubSession{ model: vault.Model{ Entries: []vault.Entry{ {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}}, }, }, }, CurrentPath: []string{"Crew", "Internet"}, } if err := state.SelectVisibleIndex(1); err != nil { t.Fatalf("SelectVisibleIndex() error = %v", err) } if got := state.SelectedEntryID; got != "vault-console" { t.Fatalf("SelectedEntryID = %q, want %q", got, "vault-console") } if err := state.ToggleVisibleIndex(1); err != nil { t.Fatalf("ToggleVisibleIndex() error = %v", err) } if got := state.SelectedEntryID; got != "" { t.Fatalf("SelectedEntryID after toggle = %q, want empty", got) } } func TestVisibleEntriesFailsWhenVaultIsLocked(t *testing.T) { t.Parallel() state := State{ Session: stubSession{err: session.ErrLocked}, } _, err := state.VisibleEntries() if !errors.Is(err, session.ErrLocked) { t.Fatalf("VisibleEntries() error = %v, want ErrLocked", err) } } type stubSession struct { model vault.Model err error } func (s stubSession) Current() (vault.Model, error) { if s.err != nil { return vault.Model{}, s.err } return s.model, nil } func TestDeleteSelectedEntryUpdatesSessionAndClearsSelection(t *testing.T) { t.Parallel() sess := &mutableStubSession{ model: vault.Model{ Entries: []vault.Entry{ {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}}, }, }, } state := State{ Session: sess, CurrentPath: []string{"Root", "Internet"}, SelectedEntryID: "vault-console", } if err := state.DeleteSelectedEntry(); err != nil { t.Fatalf("DeleteSelectedEntry() error = %v", err) } if got := state.SelectedEntryID; got != "" { t.Fatalf("SelectedEntryID = %q, want empty", got) } if len(sess.model.Entries) != 0 { t.Fatalf("len(Entries) = %d, want 0", len(sess.model.Entries)) } if len(sess.model.RecycleBin) != 1 || sess.model.RecycleBin[0].ID != "vault-console" { t.Fatalf("RecycleBin = %#v, want vault-console entry", sess.model.RecycleBin) } if !state.Dirty { t.Fatal("Dirty = false, want true after delete") } } func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) { t.Parallel() sess := &mutableStubSession{ model: vault.Model{ RecycleBin: []vault.Entry{ {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}}, }, }, } state := State{ Session: sess, CurrentPath: []string{"Root", "Internet"}, } if err := state.RestoreEntry("vault-console"); err != nil { t.Fatalf("RestoreEntry() error = %v", err) } got, err := state.VisibleEntries() if err != nil { t.Fatalf("VisibleEntries() error = %v", err) } if len(got) != 1 || got[0].ID != "vault-console" { t.Fatalf("VisibleEntries() = %#v, want restored vault-console entry", got) } if !state.Dirty { t.Fatal("Dirty = false, want true after restore") } } func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) { t.Parallel() sess := &mutableStubSession{ model: vault.Model{}, } state := State{ Session: sess, CurrentPath: []string{"Root", "Internet"}, } entry := vault.Entry{ ID: "vault-console", Title: "Vault Console", Username: "dannyocean", Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, } if err := state.UpsertEntry(entry); err != nil { t.Fatalf("UpsertEntry() error = %v", err) } if got := state.SelectedEntryID; got != "vault-console" { t.Fatalf("SelectedEntryID = %q, want %q", got, "vault-console") } got, err := state.VisibleEntries() if err != nil { t.Fatalf("VisibleEntries() error = %v", err) } if len(got) != 1 || got[0].Password != "bellagio-pass-1" { t.Fatalf("VisibleEntries() = %#v, want persisted vault-console entry", got) } if !state.Dirty { t.Fatal("Dirty = false, want true after upsert") } } func TestInstantiateTemplateCreatesEntryAndSelectsIt(t *testing.T) { t.Parallel() sess := &mutableStubSession{ model: vault.Model{ Templates: []vault.Entry{ { ID: "website-login", Title: "Website Login", Username: "template-user", Password: "template-password", URL: "https://example.com", Notes: "Reusable template for website accounts.", Path: []string{"Templates"}, }, }, }, } state := State{ Session: sess, CurrentPath: []string{"Root", "Internet"}, } entry, err := state.InstantiateTemplate("website-login", vault.Entry{ ID: "bellagio", Title: "Bellagio", Username: "rustyryan", Password: "hunter2", URL: "https://bellagio.example.invalid", Path: []string{"Root", "Internet"}, }) if err != nil { t.Fatalf("InstantiateTemplate() error = %v", err) } if entry.Notes != "Reusable template for website accounts." { t.Fatalf("entry.Notes = %q, want template notes", entry.Notes) } if got := state.SelectedEntryID; got != "bellagio" { t.Fatalf("SelectedEntryID = %q, want %q", got, "bellagio") } got, err := state.VisibleEntries() if err != nil { t.Fatalf("VisibleEntries() error = %v", err) } if len(got) != 1 || got[0].ID != "bellagio" { t.Fatalf("VisibleEntries() = %#v, want instantiated bellagio entry", got) } if !state.Dirty { t.Fatal("Dirty = false, want true after template instantiation") } } func TestUpsertTemplateCreatesTemplateAndMarksStateDirty(t *testing.T) { t.Parallel() sess := &mutableStubSession{model: vault.Model{}} state := State{Session: sess} if err := state.UpsertTemplate(vault.Entry{ ID: "tpl-1", Title: "Website Login", Username: "template-user", Path: []string{"Templates"}, }); err != nil { t.Fatalf("UpsertTemplate() error = %v", err) } if len(sess.model.Templates) != 1 || sess.model.Templates[0].ID != "tpl-1" { t.Fatalf("Templates = %#v, want tpl-1 template", sess.model.Templates) } if state.SelectedEntryID != "tpl-1" { t.Fatalf("SelectedEntryID = %q, want tpl-1 after template upsert", state.SelectedEntryID) } if !state.Dirty { t.Fatal("Dirty = false, want true after template upsert") } } func TestDeleteTemplateRemovesTemplateAndClearsSelection(t *testing.T) { t.Parallel() sess := &mutableStubSession{model: vault.Model{ Templates: []vault.Entry{ {ID: "tpl-1", Title: "Website Login", Path: []string{"Templates"}}, }, }} state := State{ Session: sess, SelectedEntryID: "tpl-1", } if err := state.DeleteTemplate("tpl-1"); err != nil { t.Fatalf("DeleteTemplate() error = %v", err) } if len(sess.model.Templates) != 0 { t.Fatalf("Templates = %#v, want empty after delete", sess.model.Templates) } if state.SelectedEntryID != "" { t.Fatalf("SelectedEntryID = %q, want empty after delete", state.SelectedEntryID) } if !state.Dirty { t.Fatal("Dirty = false, want true after template delete") } } func TestDuplicateSelectedEntryCreatesCopyAndSelectsIt(t *testing.T) { t.Parallel() sess := &mutableStubSession{model: vault.Model{ Entries: []vault.Entry{ {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}}, }, }} state := State{ Session: sess, SelectedEntryID: "vault-console", } duplicate, err := state.DuplicateSelectedEntry("vault-console-copy") if err != nil { t.Fatalf("DuplicateSelectedEntry() error = %v", err) } if duplicate.ID != "vault-console-copy" { t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "vault-console-copy") } if state.SelectedEntryID != "vault-console-copy" { t.Fatalf("SelectedEntryID = %q, want vault-console-copy", state.SelectedEntryID) } if len(sess.model.Entries) != 2 { t.Fatalf("len(Entries) = %d, want 2 after duplicate", len(sess.model.Entries)) } } func TestMoveSelectedEntryMovesEntryToNewPathAndMarksDirty(t *testing.T) { t.Parallel() sess := &mutableStubSession{model: vault.Model{ Entries: []vault.Entry{ {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}}, }, }} state := State{ Session: sess, CurrentPath: []string{"Root", "Internet"}, SelectedEntryID: "vault-console", } if err := state.MoveSelectedEntry([]string{"Root", "Infrastructure"}); err != nil { t.Fatalf("MoveSelectedEntry() error = %v", err) } oldPath := sess.model.EntriesInPath([]string{"Root", "Internet"}) if len(oldPath) != 0 { t.Fatalf("EntriesInPath(Root/Internet) = %#v, want empty after move", oldPath) } newPath := sess.model.EntriesInPath([]string{"Root", "Infrastructure"}) if len(newPath) != 1 || newPath[0].ID != "vault-console" { t.Fatalf("EntriesInPath(Root/Infrastructure) = %#v, want moved vault-console entry", newPath) } if !state.Dirty { t.Fatal("Dirty = false, want true after move") } } func TestRestoreSelectedEntryVersionReplacesCurrentVersion(t *testing.T) { t.Parallel() sess := &mutableStubSession{model: vault.Model{ Entries: []vault.Entry{ { ID: "vault-console", Title: "Vault Console", Password: "new-token", Path: []string{"Root", "Internet"}, History: []vault.Entry{ {ID: "vault-console-h1", Title: "Vault Console", Password: "old-token", Path: []string{"Root", "Internet"}}, }, }, }, }} state := State{ Session: sess, SelectedEntryID: "vault-console", } if err := state.RestoreSelectedEntryVersion(0); err != nil { t.Fatalf("RestoreSelectedEntryVersion() error = %v", err) } if got := sess.model.Entries[0].Password; got != "old-token" { t.Fatalf("Entries[0].Password = %q, want %q", got, "old-token") } if !state.Dirty { t.Fatal("Dirty = false, want true after history restore") } } func TestSaveClearsDirtyState(t *testing.T) { t.Parallel() sess := &saveableStubSession{} state := State{ Session: sess, Dirty: true, } if err := state.Save(); err != nil { t.Fatalf("Save() error = %v", err) } if state.Dirty { t.Fatal("Dirty = true, want false after save") } if sess.saveCalls != 1 { t.Fatalf("saveCalls = %d, want 1", sess.saveCalls) } } func TestCreateVaultResetsSelectionPathAndDirtyState(t *testing.T) { t.Parallel() sess := &lifecycleStubSession{} state := State{ Session: sess, CurrentPath: []string{"Root", "Internet"}, SelectedEntryID: "vault-console", Dirty: true, } if err := state.CreateVault(vault.MasterKey{Password: "correct horse battery staple"}); err != nil { t.Fatalf("CreateVault() error = %v", err) } if sess.createCalls != 1 { t.Fatalf("createCalls = %d, want 1", sess.createCalls) } 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 create") } } func TestOpenVaultResetsSelectionPathAndDirtyState(t *testing.T) { t.Parallel() sess := &lifecycleStubSession{} state := State{ Session: sess, CurrentPath: []string{"Root", "Internet"}, SelectedEntryID: "vault-console", Dirty: true, } if err := state.OpenVault("/tmp/test.kdbx", vault.MasterKey{Password: "correct horse battery staple"}); err != nil { t.Fatalf("OpenVault() error = %v", err) } if sess.openPath != "/tmp/test.kdbx" { t.Fatalf("openPath = %q, want %q", sess.openPath, "/tmp/test.kdbx") } 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 open") } } func TestSaveAsClearsDirtyState(t *testing.T) { t.Parallel() sess := &lifecycleStubSession{} state := State{ Session: sess, Dirty: true, } if err := state.SaveAs("/tmp/other.kdbx"); err != nil { t.Fatalf("SaveAs() error = %v", err) } if sess.saveAsPath != "/tmp/other.kdbx" { t.Fatalf("saveAsPath = %q, want %q", sess.saveAsPath, "/tmp/other.kdbx") } if state.Dirty { t.Fatal("Dirty = true, want false after save-as") } } func TestOpenRemoteVaultResetsSelectionPathAndDirtyState(t *testing.T) { t.Parallel() sess := &lifecycleStubSession{} client := webdav.Client{BaseURL: "https://example.com"} state := State{ Session: sess, CurrentPath: []string{"Root", "Internet"}, SelectedEntryID: "vault-console", Dirty: true, } if err := state.OpenRemoteVault(client, "vaults/main.kdbx", vault.MasterKey{Password: "correct horse battery staple"}); err != nil { t.Fatalf("OpenRemoteVault() error = %v", err) } if sess.remotePath != "vaults/main.kdbx" { t.Fatalf("remotePath = %q, want %q", sess.remotePath, "vaults/main.kdbx") } 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 remote open") } } 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: "bellagio-webdav", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", }, }, }, } state := State{ Session: sess, CurrentPath: []string{"Root", "Internet"}, SelectedEntryID: "vault-console", Dirty: true, } err := state.OpenBoundRemoteVault(RemoteBinding{ LocalVaultPath: "/tmp/bellagio.kdbx", RemoteProfileID: "bellagio-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/bellagio/keepass.kdbx" { t.Fatalf("remotePath = %q, want files/bellagio/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/bellagio.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/bellagio.kdbx", RemoteProfileID: "bellagio-webdav", RemoteProfileName: "Bellagio Vault", BaseURL: "https://dav.example.invalid/remote.php/dav", RemotePath: "files/bellagio/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 != "bellagio-webdav" { t.Fatalf("binding.RemoteProfileID = %q, want bellagio-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/bellagio.kdbx", RemoteProfileID: "bellagio-webdav", BaseURL: "https://dav.example.invalid/remote.php/dav", RemotePath: "files/bellagio/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: "bellagio-webdav", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", Path: "files/bellagio/keepass.kdbx", }}, }} state := State{Session: sess} err := state.RemoveRemoteBinding(RemoteBinding{ LocalVaultPath: "/tmp/bellagio.kdbx", RemoteProfileID: "bellagio-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() sess := &lockableStubSession{ model: vault.Model{ Entries: []vault.Entry{ {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}}, }, }, } state := State{ Session: sess, CurrentPath: []string{"Root", "Internet"}, SelectedEntryID: "vault-console", } if err := state.Lock(); err != nil { t.Fatalf("Lock() error = %v", err) } if got := state.SelectedEntryID; got != "" { t.Fatalf("SelectedEntryID = %q, want empty after lock", got) } _, err := state.VisibleEntries() if !errors.Is(err, session.ErrLocked) { t.Fatalf("VisibleEntries() error = %v, want ErrLocked", err) } } func TestUnlockRestoresVaultVisibility(t *testing.T) { t.Parallel() sess := &lockableStubSession{ model: vault.Model{ Entries: []vault.Entry{ {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}}, }, }, locked: true, } state := State{ Session: sess, CurrentPath: []string{"Root", "Internet"}, } if err := state.Unlock(vault.MasterKey{Password: "correct horse battery staple"}); err != nil { t.Fatalf("Unlock() error = %v", err) } got, err := state.VisibleEntries() if err != nil { t.Fatalf("VisibleEntries() error = %v", err) } if len(got) != 1 || got[0].ID != "vault-console" { t.Fatalf("VisibleEntries() = %#v, want vault-console entry after unlock", got) } } func TestChangeMasterKeyMarksStateDirty(t *testing.T) { t.Parallel() sess := &lifecycleStubSession{} state := State{Session: sess} key := vault.MasterKey{Password: "correct horse battery staple"} if err := state.ChangeMasterKey(key); err != nil { t.Fatalf("ChangeMasterKey() error = %v", err) } if got := sess.changedKey; got.Password != key.Password { t.Fatalf("changedKey = %#v, want %#v", got, key) } if !state.Dirty { t.Fatal("Dirty = false, want true after ChangeMasterKey") } } func TestShowSectionResetsPathAndSelection(t *testing.T) { t.Parallel() state := State{ Section: SectionEntries, CurrentPath: []string{"Root", "Internet"}, SelectedEntryID: "vault-console", SearchQuery: "git", } state.ShowSection(SectionTemplates) if state.Section != SectionTemplates { t.Fatalf("Section = %q, want %q", state.Section, SectionTemplates) } 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.SearchQuery != "git" { t.Fatalf("SearchQuery = %q, want search preserved", state.SearchQuery) } } func TestSetSearchQueryUpdatesControllerSearchState(t *testing.T) { t.Parallel() state := State{} state.SetSearchQuery("lights") if state.SearchQuery != "lights" { t.Fatalf("SearchQuery = %q, want %q", state.SearchQuery, "lights") } } func TestBeginNewEntryClearsSelectionAndStatus(t *testing.T) { t.Parallel() state := State{ SelectedEntryID: "vault-console", ErrorMessage: "previous error", } state.BeginNewEntry() if state.SelectedEntryID != "" { t.Fatalf("SelectedEntryID = %q, want empty", state.SelectedEntryID) } if state.StatusMessage != "" { t.Fatalf("StatusMessage = %q, want empty", state.StatusMessage) } if state.ErrorMessage != "" { t.Fatalf("ErrorMessage = %q, want empty", state.ErrorMessage) } } func TestSetActionResultTracksSuccessAndFailureMessages(t *testing.T) { t.Parallel() state := State{} state.SetActionResult("save vault", nil) if state.StatusMessage != "save vault complete" { t.Fatalf("StatusMessage = %q, want save vault complete", state.StatusMessage) } if state.ErrorMessage != "" { t.Fatalf("ErrorMessage = %q, want empty on success", state.ErrorMessage) } state.SetActionResult("save vault", errors.New("disk full")) if state.StatusMessage != "" { t.Fatalf("StatusMessage = %q, want empty on failure", state.StatusMessage) } if state.ErrorMessage != "disk full" { t.Fatalf("ErrorMessage = %q, want disk full", state.ErrorMessage) } } func TestEnterGroupAppendsPathAndClearsSelection(t *testing.T) { t.Parallel() state := State{ CurrentPath: []string{"Root"}, SelectedEntryID: "vault-console", } state.EnterGroup("Internet") if !slices.Equal(state.CurrentPath, []string{"Root", "Internet"}) { t.Fatalf("CurrentPath = %v, want [Root Internet]", state.CurrentPath) } if got := state.SelectedEntryID; got != "" { t.Fatalf("SelectedEntryID = %q, want empty", got) } } func TestNavigateToPathReplacesPathAndClearsSelection(t *testing.T) { t.Parallel() state := State{ CurrentPath: []string{"Root", "Internet"}, SelectedEntryID: "vault-console", } state.NavigateToPath([]string{"Root", "Security Office"}) 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) } } func TestDeleteCurrentGroupMovesToParentAndMarksDirty(t *testing.T) { t.Parallel() model := testVaultModel() model.CreateGroup([]string{"Root"}, "Finance") sess := &mutableStubSession{model: model} state := State{ Session: sess, CurrentPath: []string{"Root", "Finance"}, } if err := state.DeleteCurrentGroup(); err != nil { t.Fatalf("DeleteCurrentGroup() error = %v", err) } if !slices.Equal(state.CurrentPath, []string{"Root"}) { t.Fatalf("CurrentPath = %v, want [Root]", state.CurrentPath) } if !state.Dirty { t.Fatal("Dirty = false, want true after DeleteCurrentGroup") } got, err := state.ChildGroups() if err != nil { t.Fatalf("ChildGroups() error = %v", err) } if !slices.Equal(got, []string{"Internet", "Security Office"}) { t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got) } } func TestCreateGroupPersistsGroupAndMarksDirty(t *testing.T) { t.Parallel() sess := &mutableStubSession{model: testVaultModel()} state := State{ Session: sess, CurrentPath: []string{"Root"}, } if err := state.CreateGroup("Finance"); err != nil { t.Fatalf("CreateGroup() error = %v", err) } got, err := state.ChildGroups() if err != nil { t.Fatalf("ChildGroups() error = %v", err) } 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") } } func TestCreateGroupAutoSavesWhenSessionSupportsSaving(t *testing.T) { t.Parallel() sess := &mutableSaveableStubSession{model: testVaultModel(), hasSaveTarget: true} state := State{ Session: sess, CurrentPath: []string{"Root"}, } if err := state.CreateGroup("Finance"); err != nil { t.Fatalf("CreateGroup() error = %v", err) } if sess.saveCalls != 1 { t.Fatalf("saveCalls = %d, want 1", sess.saveCalls) } if state.Dirty { t.Fatal("Dirty = true, want false after autosave") } } func TestCreateGroupSaveFailureLeavesStateDirty(t *testing.T) { t.Parallel() sess := &mutableSaveableStubSession{ model: testVaultModel(), hasSaveTarget: true, saveErr: errors.New("save failed"), } state := State{ Session: sess, CurrentPath: []string{"Root"}, } err := state.CreateGroup("Finance") if err == nil || err.Error() != "save failed" { t.Fatalf("CreateGroup() error = %v, want save failed", err) } if sess.saveCalls != 1 { t.Fatalf("saveCalls = %d, want 1", sess.saveCalls) } if !state.Dirty { t.Fatal("Dirty = false, want true after failed autosave") } got, childErr := state.ChildGroups() if childErr != nil { t.Fatalf("ChildGroups() error = %v", childErr) } if !slices.Equal(got, []string{"Finance", "Internet", "Security Office"}) { t.Fatalf("ChildGroups() = %v, want Finance, Internet, Security Office", got) } } func TestCreateGroupSupportsNestedGroupPath(t *testing.T) { t.Parallel() session := &mutableStubSession{model: vault.Model{}} state := State{ Session: session, CurrentPath: []string{"Root"}, } if err := state.CreateGroup("Infrastructure / Prod"); err != nil { t.Fatalf("CreateGroup() error = %v", err) } if got := session.model.ChildGroups([]string{"keepass"}); !slices.Equal(got, []string{"Infrastructure"}) { t.Fatalf("ChildGroups(keepass) = %v, want [Infrastructure]", got) } if got := session.model.ChildGroups([]string{"keepass", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) { t.Fatalf("ChildGroups(keepass/Infrastructure) = %v, want [Prod]", got) } } func TestRenameCurrentGroupUpdatesPathAndMarksDirty(t *testing.T) { t.Parallel() sess := &mutableStubSession{model: testVaultModel()} state := State{ Session: sess, CurrentPath: []string{"Root", "Internet"}, } if err := state.RenameCurrentGroup("Infra"); err != nil { t.Fatalf("RenameCurrentGroup() error = %v", err) } if !slices.Equal(state.CurrentPath, []string{"Root", "Infra"}) { t.Fatalf("CurrentPath = %v, want [Root Infra]", state.CurrentPath) } if !state.Dirty { t.Fatal("Dirty = false, want true after RenameCurrentGroup") } } func TestDeleteCurrentGroupRemovesItNavigatesToParentAndMarksDirty(t *testing.T) { t.Parallel() model := testVaultModel() model.CreateGroup([]string{"Root"}, "Finance") sess := &mutableStubSession{model: model} state := State{ Session: sess, CurrentPath: []string{"Root", "Finance"}, } if err := state.DeleteCurrentGroup(); err != nil { t.Fatalf("DeleteCurrentGroup() error = %v", err) } if !slices.Equal(state.CurrentPath, []string{"Root"}) { t.Fatalf("CurrentPath = %v, want [Root]", state.CurrentPath) } got, err := state.ChildGroups() if err != nil { t.Fatalf("ChildGroups() error = %v", err) } 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") } } func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) { t.Parallel() sess := &mutableStubSession{model: testVaultModel()} state := State{ Session: sess, CurrentPath: []string{"Root", "Internet"}, SelectedEntryID: "bellagio", } if err := state.MoveSelectedEntry([]string{"Root", "Security Office"}); err != nil { t.Fatalf("MoveSelectedEntry() error = %v", err) } state.NavigateToPath([]string{"Root", "Security Office"}) got, err := state.VisibleEntries() if err != nil { t.Fatalf("VisibleEntries() error = %v", err) } if len(got) != 2 { t.Fatalf("len(VisibleEntries()) = %d, want 2", len(got)) } if got[0].ID != "bellagio" && got[1].ID != "bellagio" { t.Fatalf("VisibleEntries() = %#v, want moved bellagio entry in destination group", got) } if !state.Dirty { t.Fatal("Dirty = false, want true after MoveSelectedEntry") } } func TestMoveCurrentGroupMovesHierarchyAndMarksDirty(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") session := &mutableStubSession{model: model} state := State{ Session: session, CurrentPath: []string{"Root", "Internet"}, } if err := state.MoveCurrentGroup([]string{"Root", "Crew"}); err != nil { t.Fatalf("MoveCurrentGroup() error = %v", err) } if !slices.Equal(state.CurrentPath, []string{"Root", "Crew", "Internet"}) { t.Fatalf("CurrentPath = %v, want [Root Crew Internet]", state.CurrentPath) } if got := session.model.EntriesInPath([]string{"Root", "Crew", "Internet"}); len(got) != 1 || got[0].ID != "vault-console" { t.Fatalf("EntriesInPath(Root/Crew/Internet) = %#v, want moved entry", got) } if !state.Dirty { t.Fatal("Dirty = false, want true after MoveCurrentGroup") } } func TestAddAttachmentToSelectedEntryPersistsAndMarksDirty(t *testing.T) { t.Parallel() sess := &mutableStubSession{model: testVaultModel()} state := State{ Session: sess, CurrentPath: []string{"Root", "Internet"}, SelectedEntryID: "bellagio", } if err := state.AddAttachmentToSelectedEntry("token.txt", []byte("secret")); err != nil { t.Fatalf("AddAttachmentToSelectedEntry() error = %v", err) } got, err := state.VisibleEntries() if err != nil { t.Fatalf("VisibleEntries() error = %v", err) } if string(got[0].Attachments["token.txt"]) != "secret" { t.Fatalf("attachment content = %q, want %q", got[0].Attachments["token.txt"], "secret") } if !state.Dirty { t.Fatal("Dirty = false, want true after AddAttachmentToSelectedEntry") } } func TestAddAttachmentToSelectedEntryRejectsDuplicateNames(t *testing.T) { t.Parallel() model := testVaultModel() model.Entries[0].Attachments = map[string][]byte{"token.txt": []byte("secret")} sess := &mutableStubSession{model: model} state := State{ Session: sess, CurrentPath: []string{"Root", "Internet"}, SelectedEntryID: "bellagio", } err := state.AddAttachmentToSelectedEntry("token.txt", []byte("replacement")) if !errors.Is(err, ErrAttachmentAlreadyExists) { t.Fatalf("AddAttachmentToSelectedEntry() error = %v, want ErrAttachmentAlreadyExists", err) } got, currentErr := sess.Current() if currentErr != nil { t.Fatalf("Current() error = %v", currentErr) } if string(got.Entries[0].Attachments["token.txt"]) != "secret" { t.Fatalf("attachment content = %q, want %q", got.Entries[0].Attachments["token.txt"], "secret") } } func TestReplaceAttachmentOnSelectedEntryPersistsAndMarksDirty(t *testing.T) { t.Parallel() model := testVaultModel() model.Entries[0].Attachments = map[string][]byte{"token.txt": []byte("secret")} sess := &mutableStubSession{model: model} state := State{ Session: sess, CurrentPath: []string{"Root", "Internet"}, SelectedEntryID: "bellagio", } if err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement")); err != nil { t.Fatalf("ReplaceAttachmentOnSelectedEntry() error = %v", err) } got, err := state.VisibleEntries() if err != nil { t.Fatalf("VisibleEntries() error = %v", err) } if string(got[0].Attachments["token.txt"]) != "replacement" { t.Fatalf("attachment content = %q, want %q", got[0].Attachments["token.txt"], "replacement") } if !state.Dirty { t.Fatal("Dirty = false, want true after ReplaceAttachmentOnSelectedEntry") } } func TestReplaceAttachmentOnSelectedEntryRequiresExistingAttachment(t *testing.T) { t.Parallel() sess := &mutableStubSession{model: testVaultModel()} state := State{ Session: sess, CurrentPath: []string{"Root", "Internet"}, SelectedEntryID: "bellagio", } err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement")) if !errors.Is(err, ErrAttachmentNotFound) { t.Fatalf("ReplaceAttachmentOnSelectedEntry() error = %v, want ErrAttachmentNotFound", err) } } func TestDeleteAttachmentFromSelectedEntryPersistsAndMarksDirty(t *testing.T) { t.Parallel() model := testVaultModel() model.Entries[0].Attachments = map[string][]byte{"token.txt": []byte("secret")} sess := &mutableStubSession{model: model} state := State{ Session: sess, CurrentPath: []string{"Root", "Internet"}, SelectedEntryID: "bellagio", } if err := state.DeleteAttachmentFromSelectedEntry("token.txt"); err != nil { t.Fatalf("DeleteAttachmentFromSelectedEntry() error = %v", err) } got, err := state.VisibleEntries() if err != nil { t.Fatalf("VisibleEntries() error = %v", err) } if len(got[0].Attachments) != 0 { t.Fatalf("attachments = %#v, want empty", got[0].Attachments) } if !state.Dirty { t.Fatal("Dirty = false, want true after DeleteAttachmentFromSelectedEntry") } } func TestDeleteAttachmentFromSelectedEntryRequiresExistingAttachment(t *testing.T) { t.Parallel() sess := &mutableStubSession{model: testVaultModel()} state := State{ Session: sess, CurrentPath: []string{"Root", "Internet"}, SelectedEntryID: "bellagio", } err := state.DeleteAttachmentFromSelectedEntry("token.txt") if !errors.Is(err, ErrAttachmentNotFound) { t.Fatalf("DeleteAttachmentFromSelectedEntry() error = %v, want ErrAttachmentNotFound", err) } } type mutableStubSession struct { model vault.Model err error } func (s *mutableStubSession) Current() (vault.Model, error) { if s.err != nil { return vault.Model{}, s.err } return s.model, nil } func (s *mutableStubSession) Replace(model vault.Model) { s.model = model } 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", "Security Office"}}, }, } } type lockableStubSession struct { model vault.Model locked bool } func (s *lockableStubSession) Current() (vault.Model, error) { if s.locked { return vault.Model{}, session.ErrLocked } return s.model, nil } func (s *lockableStubSession) Lock() error { s.locked = true return nil } func (s *lockableStubSession) Unlock(vault.MasterKey) error { s.locked = false return nil } type saveableStubSession struct { saveCalls int } func (s *saveableStubSession) Current() (vault.Model, error) { return vault.Model{}, nil } func (s *saveableStubSession) Save() error { s.saveCalls++ return nil } type mutableSaveableStubSession struct { model vault.Model err error saveCalls int saveErr error hasSaveTarget bool remote bool } func (s *mutableSaveableStubSession) Current() (vault.Model, error) { if s.err != nil { return vault.Model{}, s.err } return s.model, nil } func (s *mutableSaveableStubSession) Replace(model vault.Model) { s.model = model } func (s *mutableSaveableStubSession) Save() error { s.saveCalls++ return s.saveErr } func (s *mutableSaveableStubSession) HasSaveTarget() bool { return s.hasSaveTarget } func (s *mutableSaveableStubSession) IsRemote() bool { return s.remote } type lifecycleStubSession struct { 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 s.model, nil } func (s *lifecycleStubSession) Create(_ vault.Model, _ vault.MasterKey) error { s.createCalls++ return nil } func (s *lifecycleStubSession) Open(path string, _ vault.MasterKey) error { s.openPath = path return nil } func (s *lifecycleStubSession) SaveAs(path string) error { s.saveAsPath = path return nil } func (s *lifecycleStubSession) OpenRemote(client webdav.Client, path string, _ vault.MasterKey) error { s.remoteClient = client s.remotePath = path return nil } func (s *lifecycleStubSession) SynchronizeFromLocal(string) error { return nil } func (s *lifecycleStubSession) SynchronizeFromLocalBytes(string, []byte) error { return nil } func (s *lifecycleStubSession) SynchronizeToLocal(string) error { return nil } func (s *lifecycleStubSession) SynchronizeFromRemote(webdav.Client, string) error { return nil } func (s *lifecycleStubSession) SynchronizeToRemote(webdav.Client, string) error { return nil } func (s *lifecycleStubSession) ChangeMasterKey(key vault.MasterKey) error { s.changedKey = key return nil } type stubApprovalManager struct { pending []apiapproval.Request lastID string lastOutcome apiapproval.Outcome } func (s stubApprovalManager) Pending() []apiapproval.Request { return append([]apiapproval.Request(nil), s.pending...) } func (s *stubApprovalManager) Resolve(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) { s.lastID = id s.lastOutcome = outcome return apiapproval.Request{ID: id}, nil, nil }