Fix scoped gRPC persistence and autosave behavior

This commit is contained in:
Joe Julian
2026-04-11 11:03:05 -07:00
parent 0de682a3af
commit 675aeebdeb
9 changed files with 551 additions and 85 deletions
+73 -43
View File
@@ -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
+174
View File
@@ -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