Fix scoped gRPC persistence and autosave behavior
This commit is contained in:
+73
-43
@@ -55,6 +55,15 @@ type SaveableSession interface {
|
||||
Save() error
|
||||
}
|
||||
|
||||
type AutoSaveableSession interface {
|
||||
SaveableSession
|
||||
HasSaveTarget() bool
|
||||
}
|
||||
|
||||
type RemoteAwareSession interface {
|
||||
IsRemote() bool
|
||||
}
|
||||
|
||||
type SynchronizableSession interface {
|
||||
CurrentSession
|
||||
Synchronize() error
|
||||
@@ -103,6 +112,7 @@ type State struct {
|
||||
Session CurrentSession
|
||||
Approvals ApprovalManager
|
||||
AuditLog *apiaudit.Log
|
||||
AutoSaveRemote bool
|
||||
Section Section
|
||||
CurrentPath []string
|
||||
SearchQuery string
|
||||
@@ -196,7 +206,9 @@ func (s *State) IssueAPIToken(name, clientName string, expiresAt *time.Time, now
|
||||
}
|
||||
apitokens.Upsert(&model, token)
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
if err := s.markDirtyAndAutoSave(); err != nil {
|
||||
return apitokens.Token{}, "", err
|
||||
}
|
||||
s.recordTokenAudit(apiaudit.EventTokenIssued, token, "issued API token")
|
||||
return token, secret, nil
|
||||
}
|
||||
@@ -220,7 +232,9 @@ func (s *State) RotateAPIToken(id string, now time.Time) (apitokens.Token, strin
|
||||
}
|
||||
apitokens.Upsert(&model, token)
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
if err := s.markDirtyAndAutoSave(); err != nil {
|
||||
return apitokens.Token{}, "", err
|
||||
}
|
||||
s.recordTokenAudit(apiaudit.EventTokenRotated, token, "rotated API token")
|
||||
return token, secret, nil
|
||||
}
|
||||
@@ -236,7 +250,9 @@ func (s *State) UpsertAPIToken(token apitokens.Token) error {
|
||||
}
|
||||
apitokens.Upsert(&model, token)
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
if err := s.markDirtyAndAutoSave(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.recordTokenAudit(apiaudit.EventTokenUpdated, token, "updated API token")
|
||||
return nil
|
||||
}
|
||||
@@ -257,7 +273,9 @@ func (s *State) DisableAPIToken(id string) error {
|
||||
token = apitokens.Disable(token)
|
||||
apitokens.Upsert(&model, token)
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
if err := s.markDirtyAndAutoSave(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.recordTokenAudit(apiaudit.EventTokenDisabled, token, "disabled API token")
|
||||
return nil
|
||||
}
|
||||
@@ -278,7 +296,9 @@ func (s *State) RevokeAPIToken(id string, when time.Time) error {
|
||||
token = apitokens.Revoke(token, when)
|
||||
apitokens.Upsert(&model, token)
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
if err := s.markDirtyAndAutoSave(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.recordTokenAudit(apiaudit.EventTokenRevoked, token, "revoked API token")
|
||||
return nil
|
||||
}
|
||||
@@ -300,7 +320,9 @@ func (s *State) DeleteAPIToken(id string) error {
|
||||
return err
|
||||
}
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
if err := s.markDirtyAndAutoSave(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.recordTokenAudit(apiaudit.EventTokenDeleted, token, "deleted API token")
|
||||
return nil
|
||||
}
|
||||
@@ -339,8 +361,7 @@ func (s *State) ConfigureSecurity(settings vault.SecuritySettings) error {
|
||||
if err := security.ConfigureSecurity(settings); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Dirty = true
|
||||
return nil
|
||||
return s.markDirtyAndAutoSave()
|
||||
}
|
||||
|
||||
func (s *State) ShowSection(section Section) {
|
||||
@@ -568,8 +589,7 @@ func (s *State) DeleteSelectedEntry() error {
|
||||
|
||||
session.Replace(model)
|
||||
s.SelectedEntryID = ""
|
||||
s.Dirty = true
|
||||
return nil
|
||||
return s.markDirtyAndAutoSave()
|
||||
}
|
||||
|
||||
func (s *State) RestoreEntry(id string) error {
|
||||
@@ -588,8 +608,7 @@ func (s *State) RestoreEntry(id string) error {
|
||||
}
|
||||
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return nil
|
||||
return s.markDirtyAndAutoSave()
|
||||
}
|
||||
|
||||
func (s *State) UpsertEntry(entry vault.Entry) error {
|
||||
@@ -606,8 +625,7 @@ func (s *State) UpsertEntry(entry vault.Entry) error {
|
||||
model.UpsertEntry(entry)
|
||||
session.Replace(model)
|
||||
s.SelectedEntryID = entry.ID
|
||||
s.Dirty = true
|
||||
return nil
|
||||
return s.markDirtyAndAutoSave()
|
||||
}
|
||||
|
||||
func (s *State) UpsertTemplate(entry vault.Entry) error {
|
||||
@@ -624,8 +642,7 @@ func (s *State) UpsertTemplate(entry vault.Entry) error {
|
||||
model.UpsertTemplate(entry)
|
||||
session.Replace(model)
|
||||
s.SelectedEntryID = entry.ID
|
||||
s.Dirty = true
|
||||
return nil
|
||||
return s.markDirtyAndAutoSave()
|
||||
}
|
||||
|
||||
func (s *State) InstantiateTemplate(templateID string, overrides vault.Entry) (vault.Entry, error) {
|
||||
@@ -646,7 +663,9 @@ func (s *State) InstantiateTemplate(templateID string, overrides vault.Entry) (v
|
||||
|
||||
session.Replace(model)
|
||||
s.SelectedEntryID = entry.ID
|
||||
s.Dirty = true
|
||||
if err := s.markDirtyAndAutoSave(); err != nil {
|
||||
return vault.Entry{}, err
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
@@ -669,8 +688,7 @@ func (s *State) DeleteTemplate(id string) error {
|
||||
if s.SelectedEntryID == id {
|
||||
s.SelectedEntryID = ""
|
||||
}
|
||||
s.Dirty = true
|
||||
return nil
|
||||
return s.markDirtyAndAutoSave()
|
||||
}
|
||||
|
||||
func (s *State) DuplicateSelectedEntry(duplicateID string) (vault.Entry, error) {
|
||||
@@ -691,7 +709,9 @@ func (s *State) DuplicateSelectedEntry(duplicateID string) (vault.Entry, error)
|
||||
|
||||
session.Replace(model)
|
||||
s.SelectedEntryID = duplicate.ID
|
||||
s.Dirty = true
|
||||
if err := s.markDirtyAndAutoSave(); err != nil {
|
||||
return vault.Entry{}, err
|
||||
}
|
||||
return duplicate, nil
|
||||
}
|
||||
|
||||
@@ -711,8 +731,7 @@ func (s *State) RestoreSelectedEntryVersion(historyIndex int) error {
|
||||
}
|
||||
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return nil
|
||||
return s.markDirtyAndAutoSave()
|
||||
}
|
||||
|
||||
func (s *State) Lock() error {
|
||||
@@ -748,8 +767,7 @@ func (s *State) ChangeMasterKey(key vault.MasterKey) error {
|
||||
return err
|
||||
}
|
||||
|
||||
s.Dirty = true
|
||||
return nil
|
||||
return s.markDirtyAndAutoSave()
|
||||
}
|
||||
|
||||
func (s *State) EnterGroup(name string) {
|
||||
@@ -776,6 +794,25 @@ func (s *State) Save() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) markDirtyAndAutoSave() error {
|
||||
s.Dirty = true
|
||||
session, ok := s.Session.(SaveableSession)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if autosave, ok := s.Session.(AutoSaveableSession); ok && !autosave.HasSaveTarget() {
|
||||
return nil
|
||||
}
|
||||
if remote, ok := s.Session.(RemoteAwareSession); ok && remote.IsRemote() && !s.AutoSaveRemote {
|
||||
return nil
|
||||
}
|
||||
if err := session.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Dirty = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) Synchronize() error {
|
||||
session, ok := s.Session.(SynchronizableSession)
|
||||
if !ok {
|
||||
@@ -948,7 +985,9 @@ func (s *State) ConfigureRemoteBinding(input RemoteBindingInput) (RemoteBinding,
|
||||
}
|
||||
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
if err := s.markDirtyAndAutoSave(); err != nil {
|
||||
return RemoteBinding{}, err
|
||||
}
|
||||
return binding, nil
|
||||
}
|
||||
|
||||
@@ -968,8 +1007,7 @@ func (s *State) RemoveRemoteBinding(binding RemoteBinding) error {
|
||||
}
|
||||
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return nil
|
||||
return s.markDirtyAndAutoSave()
|
||||
}
|
||||
|
||||
func (s *State) CreateGroup(name string) error {
|
||||
@@ -985,8 +1023,7 @@ func (s *State) CreateGroup(name string) error {
|
||||
|
||||
model.CreateGroup(s.CurrentPath, name)
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return nil
|
||||
return s.markDirtyAndAutoSave()
|
||||
}
|
||||
|
||||
func (s *State) MoveCurrentGroup(parent []string) error {
|
||||
@@ -1006,8 +1043,7 @@ func (s *State) MoveCurrentGroup(parent []string) error {
|
||||
if len(current) > 0 {
|
||||
s.CurrentPath = append(append([]string(nil), parent...), current[len(current)-1])
|
||||
}
|
||||
s.Dirty = true
|
||||
return nil
|
||||
return s.markDirtyAndAutoSave()
|
||||
}
|
||||
|
||||
func (s *State) RenameCurrentGroup(newName string) error {
|
||||
@@ -1029,8 +1065,7 @@ func (s *State) RenameCurrentGroup(newName string) error {
|
||||
if len(s.CurrentPath) > 0 {
|
||||
s.CurrentPath = append(append([]string(nil), s.CurrentPath[:len(s.CurrentPath)-1]...), newName)
|
||||
}
|
||||
s.Dirty = true
|
||||
return nil
|
||||
return s.markDirtyAndAutoSave()
|
||||
}
|
||||
|
||||
func (s *State) MoveSelectedEntry(path []string) error {
|
||||
@@ -1049,8 +1084,7 @@ func (s *State) MoveSelectedEntry(path []string) error {
|
||||
}
|
||||
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return nil
|
||||
return s.markDirtyAndAutoSave()
|
||||
}
|
||||
|
||||
func (s *State) DeleteCurrentGroup() error {
|
||||
@@ -1073,8 +1107,7 @@ func (s *State) DeleteCurrentGroup() error {
|
||||
s.CurrentPath = append([]string(nil), s.CurrentPath[:len(s.CurrentPath)-1]...)
|
||||
}
|
||||
s.SelectedEntryID = ""
|
||||
s.Dirty = true
|
||||
return nil
|
||||
return s.markDirtyAndAutoSave()
|
||||
}
|
||||
|
||||
func (s *State) AddAttachmentToSelectedEntry(name string, content []byte) error {
|
||||
@@ -1100,8 +1133,7 @@ func (s *State) AddAttachmentToSelectedEntry(name string, content []byte) error
|
||||
}
|
||||
model.Entries[i].Attachments[name] = append([]byte(nil), content...)
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return nil
|
||||
return s.markDirtyAndAutoSave()
|
||||
}
|
||||
|
||||
return vault.ErrEntryNotFound
|
||||
@@ -1127,8 +1159,7 @@ func (s *State) ReplaceAttachmentOnSelectedEntry(name string, content []byte) er
|
||||
}
|
||||
model.Entries[i].Attachments[name] = append([]byte(nil), content...)
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return nil
|
||||
return s.markDirtyAndAutoSave()
|
||||
}
|
||||
|
||||
return vault.ErrEntryNotFound
|
||||
@@ -1157,8 +1188,7 @@ func (s *State) DeleteAttachmentFromSelectedEntry(name string) error {
|
||||
model.Entries[i].Attachments = nil
|
||||
}
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return nil
|
||||
return s.markDirtyAndAutoSave()
|
||||
}
|
||||
|
||||
return vault.ErrEntryNotFound
|
||||
|
||||
@@ -184,6 +184,93 @@ func TestIssueRotateDisableRevokeAndDeleteAPIToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@@ -1480,6 +1567,60 @@ func TestCreateGroupPersistsGroupAndMarksDirty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@@ -1816,6 +1957,39 @@ func (s *saveableStubSession) Save() error {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user