From ca7033c10165ea52eea05ace8113d4ca53fb19da Mon Sep 17 00:00:00 2001 From: Roman Volykh Date: Wed, 29 Oct 2025 18:13:28 +0200 Subject: [PATCH] refactor: Introduce engines, backend and models pkgs --- internal/app/app.go | 22 +- internal/app/app_test.go | 21 + internal/backend/connection.go | 172 ++++ internal/backend/connection_test.go | 422 ++++++++ internal/backend/interactor.go | 39 + internal/backend/interactor_test.go | 43 + internal/backend/profiles_interactor.go | 140 +++ internal/backend/profiles_interactor_test.go | 157 +++ internal/backend/secrets_interactor.go | 88 ++ internal/backend/secrets_interactor_test.go | 61 ++ internal/engines/engines_factory.go | 28 + internal/engines/engines_factory_test.go | 33 + internal/engines/fake/fake_client.go | 51 + internal/engines/secret_engine.go | 23 + internal/{ => engines}/vault/auth/aws.go | 0 internal/{ => engines}/vault/auth/azure.go | 0 internal/{ => engines}/vault/auth/cert.go | 0 internal/{ => engines}/vault/auth/gcp.go | 0 internal/{ => engines}/vault/auth/jwt.go | 0 .../{ => engines}/vault/auth/kubernetes.go | 0 internal/{ => engines}/vault/auth/ldap.go | 0 internal/{ => engines}/vault/auth/manager.go | 0 internal/{ => engines}/vault/auth/oidc.go | 0 internal/{ => engines}/vault/auth/token.go | 0 internal/{ => engines}/vault/auth/userpass.go | 0 internal/engines/vault/vault_client.go | 231 +++++ internal/engines/vault/vault_client_test.go | 25 + internal/models/connection.go | 12 + internal/models/secret.go | 19 + internal/models/status.go | 10 + internal/ui/app.go | 30 +- internal/ui/app_test.go | 12 +- internal/ui/forms/create_secret_test.go | 43 +- internal/ui/forms/delete_secret_test.go | 40 +- internal/ui/forms/edit_secret_test.go | 38 +- internal/ui/forms/manager.go | 10 +- internal/ui/forms/manager_test.go | 68 +- internal/ui/handlers/clipboard_handlers.go | 31 +- .../ui/handlers/clipboard_handlers_test.go | 18 +- internal/ui/handlers/secret_handlers.go | 33 +- internal/ui/layout.go | 37 +- internal/ui/layout_test.go | 12 +- internal/ui/panels/dependencies_test.go | 32 + internal/ui/panels/profiles_table.go | 79 +- internal/ui/panels/profiles_table_test.go | 319 ++---- internal/ui/panels/profiles_title.go | 10 +- internal/ui/panels/profiles_title_test.go | 141 ++- internal/ui/panels/secrets_metadata.go | 21 +- internal/ui/panels/secrets_metadata_test.go | 56 +- internal/ui/panels/secrets_status.go | 27 +- internal/ui/panels/secrets_status_test.go | 21 +- internal/ui/panels/secrets_title.go | 26 +- internal/ui/panels/secrets_title_test.go | 284 ++---- internal/ui/panels/secrets_tree.go | 55 +- internal/ui/panels/secrets_tree_test.go | 321 ++---- internal/ui/panels/secrets_value.go | 25 +- internal/ui/panels/secrets_value_test.go | 62 +- internal/vault/client.go | 172 ---- internal/vault/connection.go | 268 ----- internal/vault/connection_test.go | 955 ------------------ internal/vault/dependencies.go | 37 - internal/vault/dependencies_test.go | 485 --------- internal/vault/manager.go | 445 -------- internal/vault/manager_test.go | 858 ---------------- internal/vault/secrets.go | 589 ----------- internal/vault/secrets_test.go | 52 - 66 files changed, 2279 insertions(+), 5030 deletions(-) create mode 100644 internal/app/app_test.go create mode 100644 internal/backend/connection.go create mode 100644 internal/backend/connection_test.go create mode 100644 internal/backend/interactor.go create mode 100644 internal/backend/interactor_test.go create mode 100644 internal/backend/profiles_interactor.go create mode 100644 internal/backend/profiles_interactor_test.go create mode 100644 internal/backend/secrets_interactor.go create mode 100644 internal/backend/secrets_interactor_test.go create mode 100644 internal/engines/engines_factory.go create mode 100644 internal/engines/engines_factory_test.go create mode 100644 internal/engines/fake/fake_client.go create mode 100644 internal/engines/secret_engine.go rename internal/{ => engines}/vault/auth/aws.go (100%) rename internal/{ => engines}/vault/auth/azure.go (100%) rename internal/{ => engines}/vault/auth/cert.go (100%) rename internal/{ => engines}/vault/auth/gcp.go (100%) rename internal/{ => engines}/vault/auth/jwt.go (100%) rename internal/{ => engines}/vault/auth/kubernetes.go (100%) rename internal/{ => engines}/vault/auth/ldap.go (100%) rename internal/{ => engines}/vault/auth/manager.go (100%) rename internal/{ => engines}/vault/auth/oidc.go (100%) rename internal/{ => engines}/vault/auth/token.go (100%) rename internal/{ => engines}/vault/auth/userpass.go (100%) create mode 100644 internal/engines/vault/vault_client.go create mode 100644 internal/engines/vault/vault_client_test.go create mode 100644 internal/models/connection.go create mode 100644 internal/models/secret.go create mode 100644 internal/models/status.go delete mode 100644 internal/vault/client.go delete mode 100644 internal/vault/connection.go delete mode 100644 internal/vault/connection_test.go delete mode 100644 internal/vault/dependencies.go delete mode 100644 internal/vault/dependencies_test.go delete mode 100644 internal/vault/manager.go delete mode 100644 internal/vault/manager_test.go delete mode 100644 internal/vault/secrets.go delete mode 100644 internal/vault/secrets_test.go diff --git a/internal/app/app.go b/internal/app/app.go index 8c54bc3..b545ab1 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -6,17 +6,17 @@ import ( "os/signal" "syscall" + "github.com/rvolykh/vui/internal/backend" "github.com/rvolykh/vui/internal/config" "github.com/rvolykh/vui/internal/ui" - "github.com/rvolykh/vui/internal/vault" "github.com/sirupsen/logrus" ) // App represents the main application type App struct { - config *config.Config - vault *vault.Manager - logger *logrus.Logger + config *config.Config + interactor backend.Interactor + logger *logrus.Logger } // New creates a new application instance @@ -43,16 +43,16 @@ func New() (*App, error) { } logger.SetOutput(logFile) - // Initialize vault manager - vaultManager, err := vault.NewManager(cfg, logger) + // Initialize interactor + interactor, err := backend.NewInteractor(logger, cfg) if err != nil { - return nil, fmt.Errorf("failed to initialize vault manager: %w", err) + return nil, fmt.Errorf("failed to initialize interactor: %w", err) } return &App{ - config: cfg, - vault: vaultManager, - logger: logger, + config: cfg, + interactor: interactor, + logger: logger, }, nil } @@ -61,7 +61,7 @@ func (a *App) Run() error { a.logger.Info("Starting VUI application") // Create UI application - uiApp := ui.NewApp(a.config, a.vault, a.logger) + uiApp := ui.NewApp(a.config, a.interactor, a.logger) // Set up signal handling for graceful shutdown sigChan := make(chan os.Signal, 1) diff --git a/internal/app/app_test.go b/internal/app/app_test.go new file mode 100644 index 0000000..f5ff905 --- /dev/null +++ b/internal/app/app_test.go @@ -0,0 +1,21 @@ +package app + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestApp_Run(t *testing.T) { + app, err := New() + require.NoError(t, err) + assert.NotNil(t, app) + + // Set TERM to empty string to force tcell to fail immediately + require.NoError(t, os.Setenv("TERM", "")) + + err = app.Run() + assert.Error(t, err) +} diff --git a/internal/backend/connection.go b/internal/backend/connection.go new file mode 100644 index 0000000..21d2a54 --- /dev/null +++ b/internal/backend/connection.go @@ -0,0 +1,172 @@ +package backend + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/rvolykh/vui/internal/engines" + "github.com/rvolykh/vui/internal/models" + "github.com/sirupsen/logrus" +) + +var ( + connectionTimeout = 10 * time.Second +) + +type ConnectionManager struct { + clients map[string]engines.SecretEngine + status map[string]*models.ConnectionStatus + mutex sync.RWMutex + logger *logrus.Logger +} + +func NewConnectionManager(logger *logrus.Logger) *ConnectionManager { + return &ConnectionManager{ + clients: make(map[string]engines.SecretEngine), + status: make(map[string]*models.ConnectionStatus), + logger: logger, + } +} + +func (cm *ConnectionManager) AddConnection(name string, client engines.SecretEngine) { + cm.mutex.Lock() + defer cm.mutex.Unlock() + + cm.clients[name] = client + cm.status[name] = &models.ConnectionStatus{ + Status: models.StatusConnecting, + Address: client.GetAddress(), + LastCheck: time.Now(), + } +} + +func (cm *ConnectionManager) TestConnectionAsync(name string) { + cm.mutex.RLock() + client, exists := cm.clients[name] + cm.mutex.RUnlock() + + if !exists { + return + } + + go func() { + status, err := testConnection(client) + if err != nil { + cm.logger.Warnf("Failed to connect to '%s': %v", name, err) + cm.mutex.Lock() + if existingStatus, ok := cm.status[name]; ok { + existingStatus.Status = models.StatusDisconnected + existingStatus.Error = err.Error() + existingStatus.LastCheck = time.Now() + } + cm.mutex.Unlock() + return + } + + cm.mutex.Lock() + cm.status[name] = &status + cm.mutex.Unlock() + cm.logger.Infof("Updated connection status: %s (%v)", name, status.Status) + }() +} + +func (cm *ConnectionManager) RemoveConnection(name string) { + cm.mutex.Lock() + defer cm.mutex.Unlock() + + delete(cm.clients, name) + delete(cm.status, name) + cm.logger.Infof("Removed connection: %s", name) +} + +func (cm *ConnectionManager) GetConnection(name string) (engines.SecretEngine, error) { + cm.mutex.RLock() + defer cm.mutex.RUnlock() + + client, exists := cm.clients[name] + if !exists { + return nil, fmt.Errorf("connection '%s' not found", name) + } + + return client, nil +} + +func (cm *ConnectionManager) GetConnectionStatus(name string) (*models.ConnectionStatus, error) { + cm.mutex.RLock() + defer cm.mutex.RUnlock() + + status, exists := cm.status[name] + if !exists { + return nil, fmt.Errorf("connection '%s' not found", name) + } + + // Return a copy to prevent race conditions + statusCopy := *status + return &statusCopy, nil +} + +func (cm *ConnectionManager) ListConnections() []string { + cm.mutex.RLock() + defer cm.mutex.RUnlock() + + connections := make([]string, 0, len(cm.clients)) + for name := range cm.clients { + connections = append(connections, name) + } + + return connections +} + +func (cm *ConnectionManager) RefreshConnectionStatus(name string) error { + cm.mutex.Lock() + defer cm.mutex.Unlock() + + client, exists := cm.clients[name] + if !exists { + return fmt.Errorf("connection '%s' not found", name) + } + + status, err := testConnection(client) + if err != nil { + // Update status with error + if existingStatus, ok := cm.status[name]; ok { + existingStatus.Status = models.StatusDisconnected + existingStatus.Error = err.Error() + existingStatus.LastCheck = time.Now() + } + return err + } + + cm.status[name] = &status + return nil +} + +func (cm *ConnectionManager) ResetConnections() { + cm.mutex.Lock() + defer cm.mutex.Unlock() + + for name, status := range cm.status { + status.Status = models.StatusConnecting + status.Error = "" + cm.logger.Debugf("Set connection '%s' to connecting state", name) + } +} + +func testConnection(client engines.SecretEngine) (models.ConnectionStatus, error) { + ctx, cancel := context.WithTimeout(context.Background(), connectionTimeout) + defer cancel() + + status, err := client.GetStatus(ctx) + if err != nil { + return models.ConnectionStatus{ + Status: models.StatusDisconnected, + Address: client.GetAddress(), + Error: err.Error(), + LastCheck: time.Now(), + }, err + } + + return status, nil +} diff --git a/internal/backend/connection_test.go b/internal/backend/connection_test.go new file mode 100644 index 0000000..1562704 --- /dev/null +++ b/internal/backend/connection_test.go @@ -0,0 +1,422 @@ +package backend + +import ( + "errors" + "testing" + "time" + + "github.com/rvolykh/vui/internal/engines/fake" + "github.com/rvolykh/vui/internal/models" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewConnectionManager(t *testing.T) { + t.Run("creates connection manager with logger", func(t *testing.T) { + logger := logrus.New() + + cm := NewConnectionManager(logger) + + assert.NotNil(t, cm, "Expected connection manager to be created") + assert.Equal(t, logger, cm.logger, "Expected logger to be set") + assert.NotNil(t, cm.clients, "Expected clients map to be initialized") + assert.NotNil(t, cm.status, "Expected status map to be initialized") + assert.Empty(t, cm.clients, "Expected clients map to be empty") + assert.Empty(t, cm.status, "Expected status map to be empty") + }) +} + +func TestConnectionManager_AddConnection(t *testing.T) { + t.Run("adds connection successfully", func(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + cm := NewConnectionManager(logger) + client := fake.NewFakeClient() + client.RespGetAddress = "http://localhost:8200" + + cm.AddConnection("test-vault", client) + assert.Lenf(t, cm.clients, 1, "Expected 1 client, got %d", len(cm.clients)) + + retrievedClient, exists := cm.clients["test-vault"] + require.True(t, exists, "Expected client to exist") + + assert.Equal(t, client, retrievedClient, "Expected to retrieve the same client") + + status, exists := cm.status["test-vault"] + require.True(t, exists, "Expected status to exist") + + assert.Equal(t, models.StatusConnecting, status.Status, "Expected status to be connecting") + + assert.Equal(t, "http://localhost:8200", status.Address, "Expected address 'http://localhost:8200'") + }) + + t.Run("overwrites existing connection", func(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + cm := NewConnectionManager(logger) + + client1 := fake.NewFakeClient() + client1.RespGetAddress = "http://localhost:8200" + + client2 := fake.NewFakeClient() + client2.RespGetAddress = "http://localhost:8201" + + cm.AddConnection("test-vault", client1) + cm.AddConnection("test-vault", client2) + + assert.Lenf(t, cm.clients, 1, "Expected 1 client, got %d", len(cm.clients)) + + retrievedClient := cm.clients["test-vault"] + assert.Equal(t, client2, retrievedClient, "Expected second client to overwrite first") + + status := cm.status["test-vault"] + assert.Equal(t, "http://localhost:8201", status.Address, "Expected address 'http://localhost:8201'") + }) +} + +func TestConnectionManager_RemoveConnection(t *testing.T) { + t.Run("removes connection successfully", func(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + cm := NewConnectionManager(logger) + + client := fake.NewFakeClient() + client.RespGetAddress = "http://localhost:8200" + + cm.AddConnection("test-vault", client) + cm.RemoveConnection("test-vault") + + assert.Lenf(t, cm.clients, 0, "Expected 0 clients, got %d", len(cm.clients)) + assert.Lenf(t, cm.status, 0, "Expected 0 status entries, got %d", len(cm.status)) + }) + + t.Run("removing non-existent connection is safe", func(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + cm := NewConnectionManager(logger) + + cm.RemoveConnection("non-existent") + + assert.Lenf(t, cm.clients, 0, "Expected 0 clients, got %d", len(cm.clients)) + }) + + t.Run("removes only specified connection", func(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + cm := NewConnectionManager(logger) + + client1 := fake.NewFakeClient() + client1.RespGetAddress = "http://localhost:8200" + + client2 := fake.NewFakeClient() + client2.RespGetAddress = "http://localhost:8201" + + cm.AddConnection("vault1", client1) + cm.AddConnection("vault2", client2) + cm.RemoveConnection("vault1") + + assert.Lenf(t, cm.clients, 1, "Expected 1 client, got %d", len(cm.clients)) + + _, exists := cm.clients["vault2"] + assert.True(t, exists, "Expected vault2 to still exist") + + _, exists = cm.clients["vault1"] + assert.False(t, exists, "Expected vault1 to be removed") + }) +} + +func TestConnectionManager_GetConnection(t *testing.T) { + t.Run("returns connection when it exists", func(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + cm := NewConnectionManager(logger) + + client := fake.NewFakeClient() + client.RespGetAddress = "http://localhost:8200" + + cm.AddConnection("test-vault", client) + + retrievedClient, err := cm.GetConnection("test-vault") + require.NoError(t, err, "Expected no error") + + assert.Equal(t, client, retrievedClient, "Expected to retrieve the same client") + }) + + t.Run("returns error when connection not found", func(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + cm := NewConnectionManager(logger) + + _, err := cm.GetConnection("non-existent") + require.Error(t, err, "Expected error when connection not found") + + assert.Equal(t, "connection 'non-existent' not found", err.Error(), "Expected error message 'connection 'non-existent' not found'") + }) +} + +func TestConnectionManager_GetConnectionStatus(t *testing.T) { + t.Run("returns status when connection exists", func(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + cm := NewConnectionManager(logger) + + client := fake.NewFakeClient() + client.RespGetAddress = "http://localhost:8200" + + cm.AddConnection("test-vault", client) + + status, err := cm.GetConnectionStatus("test-vault") + require.NoError(t, err, "Expected no error") + + assert.Equal(t, models.StatusConnecting, status.Status, "Expected status to be connecting") + assert.Equal(t, "http://localhost:8200", status.Address, "Expected address 'http://localhost:8200'") + }) + + t.Run("returns copy of status to prevent race conditions", func(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + cm := NewConnectionManager(logger) + + client := fake.NewFakeClient() + client.RespGetAddress = "http://localhost:8200" + + cm.AddConnection("test-vault", client) + + status1, _ := cm.GetConnectionStatus("test-vault") + status2, _ := cm.GetConnectionStatus("test-vault") + + // Modify status1 + status1.Status = models.StatusConnected + status1.Error = "test error" + + // status2 should not be affected + assert.Equal(t, models.StatusConnecting, status2.Status, "Expected status2 to be connecting") + assert.Empty(t, status2.Error, "Expected status2 error to be empty") + }) + + t.Run("returns error when connection not found", func(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + cm := NewConnectionManager(logger) + + _, err := cm.GetConnectionStatus("non-existent") + require.Error(t, err, "Expected error when connection not found") + + assert.Equal(t, "connection 'non-existent' not found", err.Error(), "Expected error message 'connection 'non-existent' not found'") + }) +} + +func TestConnectionManager_ListConnections(t *testing.T) { + t.Run("returns empty list when no connections", func(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + cm := NewConnectionManager(logger) + + connections := cm.ListConnections() + assert.Lenf(t, connections, 0, "Expected 0 connections, got %d", len(connections)) + }) + + t.Run("returns all connection names", func(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + cm := NewConnectionManager(logger) + + client1 := fake.NewFakeClient() + client1.RespGetAddress = "http://localhost:8200" + + client2 := fake.NewFakeClient() + client2.RespGetAddress = "http://localhost:8201" + + client3 := fake.NewFakeClient() + client3.RespGetAddress = "http://localhost:8202" + + cm.AddConnection("vault1", client1) + cm.AddConnection("vault2", client2) + cm.AddConnection("vault3", client3) + + connections := cm.ListConnections() + assert.Lenf(t, connections, 3, "Expected 3 connections, got %d", len(connections)) + + connectionMap := make(map[string]bool) + for _, name := range connections { + connectionMap[name] = true + } + + assert.True(t, connectionMap["vault1"], "Expected vault1 to be in the list") + assert.True(t, connectionMap["vault2"], "Expected vault2 to be in the list") + assert.True(t, connectionMap["vault3"], "Expected vault3 to be in the list") + }) +} + +func TestConnectionManager_ResetConnections(t *testing.T) { + t.Run("sets all connections to connecting state", func(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + cm := NewConnectionManager(logger) + + client1 := fake.NewFakeClient() + client1.RespGetAddress = "http://localhost:8200" + + client2 := fake.NewFakeClient() + client2.RespGetAddress = "http://localhost:8201" + + cm.AddConnection("vault1", client1) + cm.AddConnection("vault2", client2) + + // Set some statuses to non-connecting + cm.status["vault1"].Status = models.StatusConnected + cm.status["vault2"].Status = models.StatusConnecting + cm.status["vault2"].Error = "some error" + + cm.ResetConnections() + + assert.Equal(t, models.StatusConnecting, cm.status["vault1"].Status, "Expected vault1 to be connecting") + assert.Equal(t, models.StatusConnecting, cm.status["vault2"].Status, "Expected vault2 to be connecting") + assert.Empty(t, cm.status["vault2"].Error, "Expected vault2 error to be empty") + }) + + t.Run("does nothing when no connections", func(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + cm := NewConnectionManager(logger) + + // Should not panic + cm.ResetConnections() + + assert.Lenf(t, cm.status, 0, "Expected 0 status entries, got %d", len(cm.status)) + }) +} + +func TestConnectionManager_TestConnectionAsync(t *testing.T) { + t.Run("does nothing when connection not found", func(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + cm := NewConnectionManager(logger) + + // Should not panic + cm.TestConnectionAsync("non-existent") + + // Give it a moment to potentially start goroutine + time.Sleep(10 * time.Millisecond) + }) + + t.Run("starts async connection test for existing connection", func(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + cm := NewConnectionManager(logger) + + client := fake.NewFakeClient() + client.RespGetAddress = "http://localhost:8200" + client.RespGetStatus = models.ConnectionStatus{ + Status: models.StatusDisconnected, + Error: "test error", + } + + cm.AddConnection("test-vault", client) + + // Verify initial state + status, _ := cm.GetConnectionStatus("test-vault") + assert.Equal(t, models.StatusConnecting, status.Status, "Expected initial status to be connecting") + + // Call TestConnectionAsync - it will fail because vault is not running + cm.TestConnectionAsync("test-vault") + + // Wait for the goroutine to complete (with timeout) + connectionTimeout = 1 * time.Second + maxWait := 2 * time.Second + checkInterval := 100 * time.Millisecond + elapsed := time.Duration(0) + + for elapsed < maxWait { + time.Sleep(checkInterval) + elapsed += checkInterval + + status, _ = cm.GetConnectionStatus("test-vault") + if status.Status != models.StatusConnecting { + // Status has been updated + break + } + } + + // Verify the status was updated (should be disconnected with error) + status, _ = cm.GetConnectionStatus("test-vault") + assert.Equal(t, models.StatusDisconnected, status.Status, "Expected status to be disconnected") + assert.NotEmpty(t, status.Error, "Expected error to be set") + }) +} + +func TestConnectionManager_RefreshConnectionStatus(t *testing.T) { + t.Run("returns error when connection not found", func(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + cm := NewConnectionManager(logger) + + err := cm.RefreshConnectionStatus("non-existent") + require.Error(t, err, "Expected error when connection not found") + + assert.Equal(t, "connection 'non-existent' not found", err.Error(), "Expected error message 'connection 'non-existent' not found'") + }) + + t.Run("returns error when vault is not reachable", func(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + cm := NewConnectionManager(logger) + + client := fake.NewFakeClient() + client.RespGetAddress = "http://localhost:8200" + client.RespErr = errors.New("test error") + + cm.AddConnection("test-vault", client) + + err := cm.RefreshConnectionStatus("test-vault") + require.Error(t, err, "Expected error when vault is not reachable") + + // Verify status was updated with error + status, _ := cm.GetConnectionStatus("test-vault") + assert.Equal(t, models.StatusDisconnected, status.Status, "Expected status to be disconnected") + assert.NotEmpty(t, status.Error, "Expected error to be set") + }) +} + +func TestConnectionManager_ConcurrentAccess(t *testing.T) { + t.Run("handles concurrent reads and writes safely", func(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + cm := NewConnectionManager(logger) + + client := fake.NewFakeClient() + client.RespGetAddress = "http://localhost:8200" + + // Add initial connection + cm.AddConnection("test-vault", client) + + // Spawn multiple goroutines that access the connection manager + done := make(chan bool, 10) + + // Readers + for range 5 { + go func() { + for range 10 { + cm.GetConnection("test-vault") + cm.GetConnectionStatus("test-vault") + cm.ListConnections() + time.Sleep(time.Millisecond) + } + done <- true + }() + } + + // Writers + for i := range 5 { + go func(id int) { + for range 10 { + name := "vault-" + string(rune('0'+id)) + cm.AddConnection(name, fake.NewFakeClient()) + cm.RemoveConnection(name) + time.Sleep(time.Millisecond) + } + }(i) + } + }) +} diff --git a/internal/backend/interactor.go b/internal/backend/interactor.go new file mode 100644 index 0000000..2552122 --- /dev/null +++ b/internal/backend/interactor.go @@ -0,0 +1,39 @@ +package backend + +import ( + "fmt" + + "github.com/rvolykh/vui/internal/config" + "github.com/sirupsen/logrus" +) + +type Interactor interface { + Secrets() (SecretsInteractor, error) + Profiles() ProfileInteractor +} + +type interactor struct { + profilesInteractor *profileInteractor +} + +func NewInteractor(logger *logrus.Logger, cfg *config.Config) (*interactor, error) { + profilesInteractor, err := newProfileInteractor(logger, cfg) + if err != nil { + return nil, fmt.Errorf("failed to create profile interactor: %w", err) + } + + return &interactor{ + profilesInteractor: profilesInteractor, + }, nil +} + +func (i *interactor) Secrets() (SecretsInteractor, error) { + if i.profilesInteractor.secretsInteractor == nil { + return nil, fmt.Errorf("secrets interactor not found") + } + return i.profilesInteractor.secretsInteractor, nil +} + +func (i *interactor) Profiles() ProfileInteractor { + return i.profilesInteractor +} diff --git a/internal/backend/interactor_test.go b/internal/backend/interactor_test.go new file mode 100644 index 0000000..91a0dd1 --- /dev/null +++ b/internal/backend/interactor_test.go @@ -0,0 +1,43 @@ +package backend + +import ( + "testing" + + "github.com/rvolykh/vui/internal/config" + "github.com/rvolykh/vui/internal/engines/fake" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +func TestInteractor_Implements(t *testing.T) { + assert.Implements(t, (*Interactor)(nil), &interactor{}) +} + +func TestInteractor_NewInteractor(t *testing.T) { + cfg := &config.Config{} + logger := logrus.New() + + interactor, err := NewInteractor(logger, cfg) + assert.NoError(t, err) + assert.NotNil(t, interactor) + + t.Run("Profiles", func(t *testing.T) { + profiles := interactor.Profiles() + assert.NotNil(t, profiles) + }) + + t.Run("Secrets nil", func(t *testing.T) { + secrets, err := interactor.Secrets() + assert.Error(t, err) + assert.Nil(t, secrets) + }) + + t.Run("Secrets ok", func(t *testing.T) { + interactor.profilesInteractor.connectionMgr.AddConnection("test", fake.NewFakeClient()) + interactor.profilesInteractor.SwitchProfile("test") + + secrets, err := interactor.Secrets() + assert.NoError(t, err) + assert.NotNil(t, secrets) + }) +} diff --git a/internal/backend/profiles_interactor.go b/internal/backend/profiles_interactor.go new file mode 100644 index 0000000..cb4fbad --- /dev/null +++ b/internal/backend/profiles_interactor.go @@ -0,0 +1,140 @@ +package backend + +import ( + "fmt" + + "github.com/rvolykh/vui/internal/config" + "github.com/rvolykh/vui/internal/engines" + "github.com/rvolykh/vui/internal/models" + "github.com/sirupsen/logrus" +) + +type ProfileInteractor interface { + SwitchProfile(name string) error + GetCurrentProfile() string + + ListConnections() []string + RefreshConnection(name string) + ResetConnections() + ReloadConfiguration() error + GetConnectionStatus(name string) (*models.ConnectionStatus, error) +} + +type profileInteractor struct { + config *config.Config + currentProfile string + secretsInteractor SecretsInteractor + connectionMgr *ConnectionManager + logger *logrus.Logger +} + +func newProfileInteractor(logger *logrus.Logger, cfg *config.Config) (*profileInteractor, error) { + interactor := &profileInteractor{ + config: cfg, + connectionMgr: NewConnectionManager(logger), + logger: logger, + } + + // Initialize all configured vault clients + if err := interactor.initializeConnections(); err != nil { + return nil, fmt.Errorf("failed to initialize profiles: %w", err) + } + // Test connections asynchronously + interactor.testAllConnectionsAsync() + + return interactor, nil +} + +// SwitchVault switches to a different vault +func (i *profileInteractor) SwitchProfile(name string) error { + client, err := i.connectionMgr.GetConnection(name) + if err != nil { + return fmt.Errorf("failed to get connection: %w", err) + } + + // Authenticate the client when switching + if err := client.Authenticate(); err != nil { + i.logger.Errorf("Failed to authenticate to vault '%s': %v", name, err) + return fmt.Errorf("failed to authenticate: %w", err) + } + + // Update secrets manager for the new active vault + i.currentProfile = name + i.secretsInteractor = newSecretsInteractor(i.logger, name, client) + + i.logger.Infof("Switched to vault: %s", name) + return nil +} + +func (i *profileInteractor) GetCurrentProfile() string { + return i.currentProfile +} + +func (i *profileInteractor) ListConnections() []string { + return i.connectionMgr.ListConnections() +} + +func (i *profileInteractor) RefreshConnection(name string) { + i.connectionMgr.RefreshConnectionStatus(name) +} + +func (i *profileInteractor) ResetConnections() { + i.connectionMgr.ResetConnections() +} + +func (i *profileInteractor) GetConnectionStatus(name string) (*models.ConnectionStatus, error) { + return i.connectionMgr.GetConnectionStatus(name) +} + +func (i *profileInteractor) ReloadConfiguration() error { + i.logger.Info("Reloading configuration from disk...") + + // Reload config from disk + newConfig, err := config.Load() + if err != nil { + return fmt.Errorf("failed to reload configuration: %w", err) + } + i.config = newConfig + + // Remove all connections + for _, name := range i.connectionMgr.ListConnections() { + i.connectionMgr.RemoveConnection(name) + } + i.currentProfile = "" + + // Initialize all configured vault clients + if err := i.initializeConnections(); err != nil { + return fmt.Errorf("failed to initialize profiles: %w", err) + } + + // Test connections asynchronously + i.logger.Info("Re-testing all vault connections...") + i.testAllConnectionsAsync() + + i.logger.Info("Configuration reloaded successfully") + return nil +} + +func (i *profileInteractor) initializeConnections() error { + factory := engines.NewEnginesFactory(i.logger) + + for name, profile := range i.config.Vaults { + p := profile + + client, err := factory.SetupEngine("vault", &p) // TODO: add support for other engines + if err != nil { + i.logger.Warnf("Failed to setup engine for profile '%s': %v", name, err) + continue + } + + i.connectionMgr.AddConnection(name, client) + } + + return nil +} + +func (i *profileInteractor) testAllConnectionsAsync() { + for _, name := range i.connectionMgr.ListConnections() { + i.connectionMgr.TestConnectionAsync(name) + } +} diff --git a/internal/backend/profiles_interactor_test.go b/internal/backend/profiles_interactor_test.go new file mode 100644 index 0000000..030f949 --- /dev/null +++ b/internal/backend/profiles_interactor_test.go @@ -0,0 +1,157 @@ +package backend + +import ( + "testing" + + "github.com/rvolykh/vui/internal/config" + "github.com/rvolykh/vui/internal/engines/fake" + "github.com/rvolykh/vui/internal/models" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProfilesInteractor_Implements(t *testing.T) { + assert.Implements(t, (*ProfileInteractor)(nil), &profileInteractor{}) +} + +func TestProfilesInteractor_newProfileInteractor(t *testing.T) { + cfg := &config.Config{} + logger := logrus.New() + + pi, err := newProfileInteractor(logger, cfg) + require.NoError(t, err) + + assert.NotNil(t, pi) +} + +func TestProfilesInteractor_SwitchProfile(t *testing.T) { + logger := logrus.New() + cfg := &config.Config{} + + t.Run("found", func(t *testing.T) { + pi := profileInteractor{ + logger: logger, + config: cfg, + connectionMgr: NewConnectionManager(logger), + } + pi.connectionMgr.AddConnection("test", fake.NewFakeClient()) + + err := pi.SwitchProfile("test") + + assert.NoError(t, err) + assert.Equal(t, "test", pi.currentProfile) + assert.NotNil(t, pi.secretsInteractor) + assert.NotNil(t, pi.connectionMgr) + assert.NotNil(t, pi.logger) + assert.NotNil(t, pi.config) + }) + + t.Run("not found", func(t *testing.T) { + pi := profileInteractor{ + logger: logger, + config: cfg, + connectionMgr: NewConnectionManager(logger), + } + + err := pi.SwitchProfile("test") + + assert.Error(t, err) + assert.Equal(t, "", pi.currentProfile) + assert.Nil(t, pi.secretsInteractor) + assert.NotNil(t, pi.connectionMgr) + assert.NotNil(t, pi.logger) + assert.NotNil(t, pi.config) + }) +} + +func TestProfilesInteractor_ListConnections(t *testing.T) { + logger := logrus.New() + cfg := &config.Config{} + + pi := profileInteractor{ + logger: logger, + config: cfg, + connectionMgr: NewConnectionManager(logger), + } + pi.connectionMgr.AddConnection("test", fake.NewFakeClient()) + + have := pi.ListConnections() + assert.Len(t, have, 1) + assert.Equal(t, "test", have[0]) +} + +func TestProfilesInteractor_RefreshConnection(t *testing.T) { + logger := logrus.New() + cfg := &config.Config{} + + pi := profileInteractor{ + logger: logger, + config: cfg, + connectionMgr: NewConnectionManager(logger), + } + client := fake.NewFakeClient() + client.RespGetStatus = models.ConnectionStatus{ + Status: models.StatusConnected, + } + pi.connectionMgr.AddConnection("test", client) + + pi.RefreshConnection("test") + + status, err := pi.GetConnectionStatus("test") + assert.NoError(t, err) + assert.Equal(t, models.StatusConnected, status.Status) +} + +func TestProfilesInteractor_ResetConnections(t *testing.T) { + logger := logrus.New() + cfg := &config.Config{} + + pi := profileInteractor{ + logger: logger, + config: cfg, + connectionMgr: NewConnectionManager(logger), + } + pi.connectionMgr.AddConnection("test", fake.NewFakeClient()) + + pi.ResetConnections() + + status, err := pi.GetConnectionStatus("test") + assert.NoError(t, err) + assert.Equal(t, models.StatusConnecting, status.Status) +} + +func TestProfilesInteractor_ReloadConfiguration(t *testing.T) { + logger := logrus.New() + cfg := &config.Config{} + + pi := profileInteractor{ + logger: logger, + config: cfg, + connectionMgr: NewConnectionManager(logger), + } + pi.connectionMgr.AddConnection("test", fake.NewFakeClient()) + + err := pi.ReloadConfiguration() + assert.NoError(t, err) + + have := pi.ListConnections() + assert.Len(t, have, 1) + assert.Equal(t, "local", have[0]) +} + +func TestProfilesInteractor_GetCurrentProfile(t *testing.T) { + logger := logrus.New() + cfg := &config.Config{} + + pi := profileInteractor{ + logger: logger, + config: cfg, + connectionMgr: NewConnectionManager(logger), + } + pi.connectionMgr.AddConnection("test", fake.NewFakeClient()) + pi.currentProfile = "test" + + have := pi.GetCurrentProfile() + assert.Equal(t, "test", have) +} diff --git a/internal/backend/secrets_interactor.go b/internal/backend/secrets_interactor.go new file mode 100644 index 0000000..a3c7073 --- /dev/null +++ b/internal/backend/secrets_interactor.go @@ -0,0 +1,88 @@ +package backend + +import ( + "path/filepath" + + "github.com/rvolykh/vui/internal/engines" + "github.com/rvolykh/vui/internal/models" + "github.com/sirupsen/logrus" +) + +type SecretsInteractor interface { + engines.SecretClient + BuildTree(rootPath string, maxDepth int) (*models.SecretNode, error) +} + +type secretsInteractor struct { + name string + logger *logrus.Logger + engines.SecretClient +} + +func newSecretsInteractor(logger *logrus.Logger, name string, client engines.SecretClient) SecretsInteractor { + return &secretsInteractor{ + logger: logger, + name: name, + SecretClient: client, + } +} + +func (i *secretsInteractor) BuildTree(rootPath string, maxDepth int) (*models.SecretNode, error) { + return i.buildTreeRecursive(rootPath, "", maxDepth, 0) +} + +// buildTreeRecursive recursively builds the secret tree +func (i *secretsInteractor) buildTreeRecursive(rootPath, currentPath string, maxDepth, currentDepth int) (*models.SecretNode, error) { + if currentDepth >= maxDepth { + return nil, nil + } + + // List secrets at current path + secrets, err := i.ListSecrets(currentPath) + if err != nil { + return nil, err + } + + // Create root node + node := &models.SecretNode{ + Name: filepath.Base(currentPath), + Path: currentPath, + IsSecret: false, + Children: []*models.SecretNode{}, + } + + // If this is the root, use the provided name + if currentPath == "" { + node.Name = rootPath + if rootPath == "" { + node.Name = "secrets" + } + } + + // Process each secret/directory + for _, secret := range secrets { + if secret.IsSecret { + // This is a secret, get its data + secretNode, err := i.GetSecret(secret.Path) + if err != nil { + i.logger.Warnf("Failed to get secret '%s': %v", secret.Path, err) + // Add node without data if we can't retrieve it + node.Children = append(node.Children, secret) + continue + } + node.Children = append(node.Children, secretNode) + } else { + // This is a directory, recurse + childNode, err := i.buildTreeRecursive(rootPath, secret.Path, maxDepth, currentDepth+1) + if err != nil { + i.logger.Warnf("Failed to build tree for path '%s': %v", secret.Path, err) + continue + } + if childNode != nil { + node.Children = append(node.Children, childNode) + } + } + } + + return node, nil +} diff --git a/internal/backend/secrets_interactor_test.go b/internal/backend/secrets_interactor_test.go new file mode 100644 index 0000000..ab7e9a1 --- /dev/null +++ b/internal/backend/secrets_interactor_test.go @@ -0,0 +1,61 @@ +package backend + +import ( + "errors" + "testing" + + "github.com/rvolykh/vui/internal/engines/fake" + "github.com/rvolykh/vui/internal/models" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +func TestSecretsInteractor_Implements(t *testing.T) { + assert.Implements(t, (*SecretsInteractor)(nil), &secretsInteractor{}) +} + +func TestSecretsInteractor_BuildTree(t *testing.T) { + t.Run("secret", func(t *testing.T) { + client := fake.NewFakeClient() + client.RespListSecrets = []*models.SecretNode{{Name: "test", Path: "test", IsSecret: true}} + interactor := newSecretsInteractor(logrus.New(), "test", client) + + tree, err := interactor.BuildTree("test", 1) + + assert.NoError(t, err) + assert.NotNil(t, tree) + }) + + t.Run("folder", func(t *testing.T) { + client := fake.NewFakeClient() + client.RespListSecrets = []*models.SecretNode{{Name: "test", Path: "test", IsSecret: false}} + interactor := newSecretsInteractor(logrus.New(), "test", client) + + tree, err := interactor.BuildTree("test", 1) + + assert.NoError(t, err) + assert.NotNil(t, tree) + }) + + t.Run("no secrets", func(t *testing.T) { + client := fake.NewFakeClient() + client.RespListSecrets = []*models.SecretNode{} + interactor := newSecretsInteractor(logrus.New(), "test", client) + + tree, err := interactor.BuildTree("", 1) + + assert.NoError(t, err) + assert.NotNil(t, tree) + }) + + t.Run("list failure", func(t *testing.T) { + client := fake.NewFakeClient() + client.RespErr = errors.New("test error") + interactor := newSecretsInteractor(logrus.New(), "test", client) + + tree, err := interactor.BuildTree("test", 1) + + assert.Error(t, err) + assert.Nil(t, tree) + }) +} diff --git a/internal/engines/engines_factory.go b/internal/engines/engines_factory.go new file mode 100644 index 0000000..21f87d7 --- /dev/null +++ b/internal/engines/engines_factory.go @@ -0,0 +1,28 @@ +package engines + +import ( + "fmt" + + "github.com/rvolykh/vui/internal/config" + "github.com/rvolykh/vui/internal/engines/vault" + "github.com/sirupsen/logrus" +) + +type EnginesFactory struct { + logger *logrus.Logger +} + +func NewEnginesFactory(logger *logrus.Logger) *EnginesFactory { + return &EnginesFactory{ + logger: logger, + } +} + +func (f *EnginesFactory) SetupEngine(name string, profile *config.VaultProfile) (SecretEngine, error) { + switch name { + case "vault": + return vault.NewVaultClient(f.logger, profile) + default: + return nil, fmt.Errorf("unknown engine: %s", name) + } +} diff --git a/internal/engines/engines_factory_test.go b/internal/engines/engines_factory_test.go new file mode 100644 index 0000000..fc4b81f --- /dev/null +++ b/internal/engines/engines_factory_test.go @@ -0,0 +1,33 @@ +package engines + +import ( + "testing" + + "github.com/rvolykh/vui/internal/config" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEnginesFactory_SetupEngine(t *testing.T) { + factory := NewEnginesFactory(logrus.New()) + assert.NotNil(t, factory) + + t.Run("vault", func(t *testing.T) { + engine, err := factory.SetupEngine("vault", &config.VaultProfile{ + Address: "http://localhost:8200", + AuthMethod: "token", + }) + require.NoError(t, err) + assert.NotNil(t, engine) + }) + + t.Run("unknown", func(t *testing.T) { + engine, err := factory.SetupEngine("unknown", &config.VaultProfile{ + Address: "http://localhost:8200", + }) + assert.Error(t, err) + assert.ErrorContains(t, err, "unknown engine") + assert.Nil(t, engine) + }) +} diff --git a/internal/engines/fake/fake_client.go b/internal/engines/fake/fake_client.go new file mode 100644 index 0000000..e257dcd --- /dev/null +++ b/internal/engines/fake/fake_client.go @@ -0,0 +1,51 @@ +package fake + +import ( + "context" + + "github.com/rvolykh/vui/internal/models" +) + +type FakeClient struct { + RespGetStatus models.ConnectionStatus + RespListSecrets []*models.SecretNode + RespGetSecret *models.SecretNode + RespGetAddress string + RespErr error +} + +func NewFakeClient() *FakeClient { + return &FakeClient{} +} + +func (c *FakeClient) ListSecrets(path string) ([]*models.SecretNode, error) { + return c.RespListSecrets, c.RespErr +} + +func (c *FakeClient) GetSecret(path string) (*models.SecretNode, error) { + return c.RespGetSecret, c.RespErr +} + +func (c *FakeClient) CreateSecret(path string, data map[string]any) error { + return c.RespErr +} + +func (c *FakeClient) UpdateSecret(path string, data map[string]any) error { + return c.RespErr +} + +func (c *FakeClient) DeleteSecret(path string) error { + return c.RespErr +} + +func (c *FakeClient) GetStatus(ctx context.Context) (models.ConnectionStatus, error) { + return c.RespGetStatus, c.RespErr +} + +func (c *FakeClient) GetAddress() string { + return c.RespGetAddress +} + +func (c *FakeClient) Authenticate() error { + return c.RespErr +} diff --git a/internal/engines/secret_engine.go b/internal/engines/secret_engine.go new file mode 100644 index 0000000..e1bdae7 --- /dev/null +++ b/internal/engines/secret_engine.go @@ -0,0 +1,23 @@ +package engines + +import ( + "context" + + "github.com/rvolykh/vui/internal/models" +) + +type SecretEngine interface { + SecretClient + + Authenticate() error + GetAddress() string + GetStatus(ctx context.Context) (models.ConnectionStatus, error) +} + +type SecretClient interface { + ListSecrets(path string) ([]*models.SecretNode, error) + GetSecret(path string) (*models.SecretNode, error) + CreateSecret(path string, data map[string]any) error + UpdateSecret(path string, data map[string]any) error + DeleteSecret(path string) error +} diff --git a/internal/vault/auth/aws.go b/internal/engines/vault/auth/aws.go similarity index 100% rename from internal/vault/auth/aws.go rename to internal/engines/vault/auth/aws.go diff --git a/internal/vault/auth/azure.go b/internal/engines/vault/auth/azure.go similarity index 100% rename from internal/vault/auth/azure.go rename to internal/engines/vault/auth/azure.go diff --git a/internal/vault/auth/cert.go b/internal/engines/vault/auth/cert.go similarity index 100% rename from internal/vault/auth/cert.go rename to internal/engines/vault/auth/cert.go diff --git a/internal/vault/auth/gcp.go b/internal/engines/vault/auth/gcp.go similarity index 100% rename from internal/vault/auth/gcp.go rename to internal/engines/vault/auth/gcp.go diff --git a/internal/vault/auth/jwt.go b/internal/engines/vault/auth/jwt.go similarity index 100% rename from internal/vault/auth/jwt.go rename to internal/engines/vault/auth/jwt.go diff --git a/internal/vault/auth/kubernetes.go b/internal/engines/vault/auth/kubernetes.go similarity index 100% rename from internal/vault/auth/kubernetes.go rename to internal/engines/vault/auth/kubernetes.go diff --git a/internal/vault/auth/ldap.go b/internal/engines/vault/auth/ldap.go similarity index 100% rename from internal/vault/auth/ldap.go rename to internal/engines/vault/auth/ldap.go diff --git a/internal/vault/auth/manager.go b/internal/engines/vault/auth/manager.go similarity index 100% rename from internal/vault/auth/manager.go rename to internal/engines/vault/auth/manager.go diff --git a/internal/vault/auth/oidc.go b/internal/engines/vault/auth/oidc.go similarity index 100% rename from internal/vault/auth/oidc.go rename to internal/engines/vault/auth/oidc.go diff --git a/internal/vault/auth/token.go b/internal/engines/vault/auth/token.go similarity index 100% rename from internal/vault/auth/token.go rename to internal/engines/vault/auth/token.go diff --git a/internal/vault/auth/userpass.go b/internal/engines/vault/auth/userpass.go similarity index 100% rename from internal/vault/auth/userpass.go rename to internal/engines/vault/auth/userpass.go diff --git a/internal/engines/vault/vault_client.go b/internal/engines/vault/vault_client.go new file mode 100644 index 0000000..e8ca5ff --- /dev/null +++ b/internal/engines/vault/vault_client.go @@ -0,0 +1,231 @@ +package vault + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/hashicorp/vault/api" + "github.com/rvolykh/vui/internal/config" + "github.com/rvolykh/vui/internal/engines/vault/auth" + "github.com/rvolykh/vui/internal/models" + "github.com/sirupsen/logrus" +) + +type VaultClient struct { + apiClient *api.Client + profile *config.VaultProfile + logger *logrus.Logger +} + +func NewVaultClient(logger *logrus.Logger, profile *config.VaultProfile) (*VaultClient, error) { + apiConfig := api.DefaultConfig() + apiConfig.Address = profile.Address + + if profile.CertPath != "" { + if err := apiConfig.ConfigureTLS(&api.TLSConfig{ + CACert: profile.CertPath, + }); err != nil { + return nil, fmt.Errorf("failed to configure TLS: %w", err) + } + } + + apiClient, err := api.NewClient(apiConfig) + if err != nil { + return nil, fmt.Errorf("failed to create vault client: %w", err) + } + + // Set namespace if provided + if profile.Namespace != "" { + apiClient.SetNamespace(profile.Namespace) + } + + return &VaultClient{ + apiClient: apiClient, + profile: profile, + logger: logger, + }, nil +} + +func (c *VaultClient) Authenticate() error { + authManager := auth.NewAuthManager(c.logger) + + if err := authManager.Authenticate(c.apiClient, c.profile); err != nil { + return fmt.Errorf("failed to authenticate: %w", err) + } + + if err := authManager.VerifyAuthentication(c.apiClient); err != nil { + return fmt.Errorf("failed to verify authentication: %w", err) + } + + return nil +} + +func (c *VaultClient) GetAddress() string { + return c.apiClient.Address() +} + +func (c *VaultClient) GetStatus(ctx context.Context) (models.ConnectionStatus, error) { + status, err := c.apiClient.Sys().SealStatusWithContext(ctx) + if err != nil { + return models.ConnectionStatus{}, fmt.Errorf("failed to get seal status: %w", err) + } + + if status.Sealed { + return models.ConnectionStatus{ + Status: models.StatusSealed, + Address: c.GetAddress(), + Version: status.Version, + ClusterID: status.ClusterID, + }, nil + } + + return models.ConnectionStatus{ + Status: models.StatusConnected, + Address: c.GetAddress(), + Version: status.Version, + ClusterID: status.ClusterID, + }, nil +} + +func (c *VaultClient) ListSecrets(path string) ([]*models.SecretNode, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + secret, err := c.apiClient.Logical().ListWithContext(ctx, "secret/metadata/"+strings.Trim(path, "/")) + if err != nil { + return nil, fmt.Errorf("failed to list secrets at path '%s': %w", path, err) + } + + if secret == nil || secret.Data == nil { + return []*models.SecretNode{}, nil + } + + var nodes []*models.SecretNode + keys, ok := secret.Data["keys"].([]any) + if !ok { + return []*models.SecretNode{}, nil + } + for _, key := range keys { + if keyStr, ok := key.(string); ok { + isSecret := !strings.HasSuffix(keyStr, "/") + keyStr = strings.TrimSuffix(keyStr, "/") + + node := &models.SecretNode{ + Name: keyStr, + Path: filepath.Join(path, keyStr), + IsSecret: isSecret, + } + + nodes = append(nodes, node) + } + } + + return nodes, nil +} + +func (c *VaultClient) GetSecret(path string) (*models.SecretNode, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + data, err := c.apiClient.Logical().ReadWithContext(ctx, "secret/data/"+path) + if err != nil { + return nil, fmt.Errorf("failed to get secret at path '%s': %w", path, err) + } + + if data == nil || data.Data == nil { + return nil, fmt.Errorf("secret not found at path '%s'", path) + } + + metadata, err := c.apiClient.Logical().ReadWithContext(ctx, "secret/metadata/"+path) + if err != nil { + c.logger.Warnf("Failed to get metadata for secret '%s': %v", path, err) + } + + node := &models.SecretNode{ + Name: filepath.Base(path), + Path: path, + IsSecret: true, + Metadata: &models.SecretMetadata{}, + } + + // Extract data from KV v2 response + if dataMap, ok := data.Data["data"].(map[string]any); ok { + node.Data = dataMap + } else { + node.Data = data.Data + } + + if metadata != nil && metadata.Data != nil { + if createdTime, ok := metadata.Data["created_time"].(string); ok { + if t, err := time.Parse(time.RFC3339, createdTime); err == nil { + node.Metadata.CreatedTime = t + } + } + + version, ok := metadata.Data["current_version"].(json.Number) + if !ok { + return nil, fmt.Errorf("failed to get version for secret '%s'", path) + } + versionInt, err := version.Int64() + if err != nil { + return nil, fmt.Errorf("failed to get version for secret '%s': %w", path, err) + } + + node.Metadata.Version = int(versionInt) + } + + return node, nil +} + +func (c *VaultClient) CreateSecret(path string, data map[string]any) error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // For KV v2, we need to wrap the data + secretData := map[string]any{ + "data": data, + } + + _, err := c.apiClient.Logical().WriteWithContext(ctx, "secret/data/"+path, secretData) + if err != nil { + return fmt.Errorf("failed to create secret at path '%s': %w", path, err) + } + + c.logger.Infof("Created secret at path: %s", path) + return nil +} + +func (c *VaultClient) UpdateSecret(path string, data map[string]any) error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // For KV v2, we need to wrap the data + secretData := map[string]any{ + "data": data, + } + + _, err := c.apiClient.Logical().WriteWithContext(ctx, "secret/data/"+path, secretData) + if err != nil { + return fmt.Errorf("failed to update secret at path '%s': %w", path, err) + } + + c.logger.Infof("Updated secret at path: %s", path) + return nil +} + +func (c *VaultClient) DeleteSecret(path string) error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + _, err := c.apiClient.Logical().DeleteWithContext(ctx, "secret/metadata/"+path) + if err != nil { + return fmt.Errorf("failed to delete secret at path '%s': %w", path, err) + } + + c.logger.Infof("Deleted secret at path: %s", path) + return nil +} diff --git a/internal/engines/vault/vault_client_test.go b/internal/engines/vault/vault_client_test.go new file mode 100644 index 0000000..eabcbc2 --- /dev/null +++ b/internal/engines/vault/vault_client_test.go @@ -0,0 +1,25 @@ +package vault_test + +import ( + "testing" + + "github.com/rvolykh/vui/internal/config" + "github.com/rvolykh/vui/internal/engines" + "github.com/rvolykh/vui/internal/engines/vault" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/require" +) + +func TestVaultClient(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + + profile := &config.VaultProfile{ + Address: "http://localhost:8200", + } + + client, err := vault.NewVaultClient(logger, profile) + require.NoError(t, err) + + require.Implements(t, (*engines.SecretEngine)(nil), client) +} diff --git a/internal/models/connection.go b/internal/models/connection.go new file mode 100644 index 0000000..3fed108 --- /dev/null +++ b/internal/models/connection.go @@ -0,0 +1,12 @@ +package models + +import "time" + +type ConnectionStatus struct { + Status Status `json:"status"` + Address string `json:"address"` + Version string `json:"version"` + ClusterID string `json:"cluster_id"` + LastCheck time.Time `json:"last_check"` + Error string `json:"error,omitempty"` +} diff --git a/internal/models/secret.go b/internal/models/secret.go new file mode 100644 index 0000000..df9a8bd --- /dev/null +++ b/internal/models/secret.go @@ -0,0 +1,19 @@ +package models + +import "time" + +type SecretNode struct { + Name string `json:"name"` + Path string `json:"path"` + IsSecret bool `json:"is_secret"` + Children []*SecretNode `json:"children,omitempty"` + Data map[string]any `json:"data,omitempty"` + Metadata *SecretMetadata `json:"metadata,omitempty"` +} + +type SecretMetadata struct { + CreatedTime time.Time `json:"created_time"` + Version int `json:"version"` + Destroyed bool `json:"destroyed"` + DeletionTime time.Time `json:"deletion_time,omitempty"` +} diff --git a/internal/models/status.go b/internal/models/status.go new file mode 100644 index 0000000..4975a0d --- /dev/null +++ b/internal/models/status.go @@ -0,0 +1,10 @@ +package models + +type Status string + +const ( + StatusConnecting Status = "connecting" + StatusConnected Status = "connected" + StatusDisconnected Status = "disconnected" + StatusSealed Status = "sealed" +) diff --git a/internal/ui/app.go b/internal/ui/app.go index 9b02e22..2ca6484 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -5,17 +5,17 @@ import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + "github.com/rvolykh/vui/internal/backend" "github.com/rvolykh/vui/internal/config" "github.com/rvolykh/vui/internal/ui/common" "github.com/rvolykh/vui/internal/ui/panels" - "github.com/rvolykh/vui/internal/vault" "github.com/sirupsen/logrus" ) // App represents the UI application type App struct { config *config.Config - vaultMgr *vault.Manager + interactor backend.Interactor uiApp *tview.Application layout *Layout logger *logrus.Logger @@ -26,26 +26,26 @@ type App struct { } // NewApp creates a new UI application -func NewApp(config *config.Config, vaultMgr *vault.Manager, logger *logrus.Logger) *App { +func NewApp(config *config.Config, interactor backend.Interactor, logger *logrus.Logger) *App { // Initialize theme common.InitializeTheme(config.UI.Theme) uiApp := tview.NewApplication() // Create the main layout - layout := NewLayout(config, vaultMgr, logger) + layout := NewLayout(config, interactor, logger) layout.SetApplication(uiApp) // Create dialog service dialogSvc := common.NewDialogService(uiApp, nil) // Root will be set later return &App{ - config: config, - vaultMgr: vaultMgr, - uiApp: uiApp, - layout: layout, - logger: logger, - dialogSvc: dialogSvc, + config: config, + interactor: interactor, + uiApp: uiApp, + layout: layout, + logger: logger, + dialogSvc: dialogSvc, } } @@ -218,7 +218,7 @@ func (a *App) showVaultProfiles() { a.onProfilesScreen = true // Create vault profiles panel - profilesPanel := panels.NewProfilesTable(a.config, a.vaultMgr, a.uiApp, a.logger) + profilesPanel := panels.NewProfilesTable(a.config, a.interactor, a.uiApp, a.logger) if err := profilesPanel.Initialize(); err != nil { a.logger.Errorf("Failed to initialize vault profiles panel: %v", err) a.showError("Failed to initialize vault profiles") @@ -297,9 +297,9 @@ func (a *App) showVaultProfiles() { // showError displays an error message func (a *App) showError(message string) { modal := common.ErrorModal(message, func() { - // Go back to vault profiles if no connected vaults - connectedVaults := a.vaultMgr.GetConnectedConnections() - if len(connectedVaults) == 0 { + // Go back to vault profiles if no connected to profile + currentProfile := a.interactor.Profiles().GetCurrentProfile() + if currentProfile == "" { a.showVaultProfiles() } else { a.currentRoot = a.layout.GetRoot() @@ -337,6 +337,6 @@ func (a *App) GetLayout() *Layout { // buildWelcomeText creates a formatted welcome panel with connection status and navigation info func (a *App) buildWelcomeText() *tview.TextView { - welcomeScreen := panels.NewProfilesTitle(a.vaultMgr, a.hasActiveConnection) + welcomeScreen := panels.NewProfilesTitle(a.interactor, a.hasActiveConnection) return welcomeScreen.Build() } diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index 9267d7c..ab00dfe 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -3,8 +3,8 @@ package ui import ( "testing" + "github.com/rvolykh/vui/internal/backend" "github.com/rvolykh/vui/internal/config" - "github.com/rvolykh/vui/internal/vault" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" ) @@ -22,15 +22,15 @@ func TestNewApp(t *testing.T) { } logger := logrus.New() - // Create test vault manager - vaultMgr, err := vault.NewManager(cfg, logger) + // Create test interactor + interactor, err := backend.NewInteractor(logger, cfg) // This will fail because there's no vault server, but we can still test the UI creation if err != nil { t.Skip("Skipping test - no vault server available") } // Create UI app - uiApp := NewApp(cfg, vaultMgr, logger) + uiApp := NewApp(cfg, interactor, logger) assert.NotNil(t, uiApp) assert.NotNil(t, uiApp.GetUIApp()) @@ -41,9 +41,9 @@ func TestAppStructure(t *testing.T) { // Test that the app structure is correct cfg := &config.Config{} logger := logrus.New() - vaultMgr, _ := vault.NewManager(cfg, logger) + interactor, _ := backend.NewInteractor(logger, cfg) - uiApp := NewApp(cfg, vaultMgr, logger) + uiApp := NewApp(cfg, interactor, logger) // Test that we can get the underlying components assert.NotNil(t, uiApp.GetUIApp()) diff --git a/internal/ui/forms/create_secret_test.go b/internal/ui/forms/create_secret_test.go index d8a9671..c9e5a91 100644 --- a/internal/ui/forms/create_secret_test.go +++ b/internal/ui/forms/create_secret_test.go @@ -4,19 +4,21 @@ import ( "testing" "github.com/rivo/tview" + "github.com/rvolykh/vui/internal/backend" "github.com/rvolykh/vui/internal/config" - "github.com/rvolykh/vui/internal/vault" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestFormsManager_CreateSecretForm(t *testing.T) { cfg := &config.Config{} - vaultMgr := &vault.Manager{} logger := logrus.New() + interactor, err := backend.NewInteractor(logger, cfg) + require.NoError(t, err) app := tview.NewApplication() - fm := NewFormsManager(cfg, vaultMgr, logger, app) + fm := NewFormsManager(cfg, interactor, logger, app) callbackCalled := false callback := func() { @@ -31,11 +33,12 @@ func TestFormsManager_CreateSecretForm(t *testing.T) { func TestFormsManager_CreateSecretForm_WithEmptyBasePath(t *testing.T) { cfg := &config.Config{} - vaultMgr := &vault.Manager{} logger := logrus.New() + interactor, err := backend.NewInteractor(logger, cfg) + require.NoError(t, err) app := tview.NewApplication() - fm := NewFormsManager(cfg, vaultMgr, logger, app) + fm := NewFormsManager(cfg, interactor, logger, app) primitive := fm.CreateSecretForm("", func() {}) @@ -44,11 +47,12 @@ func TestFormsManager_CreateSecretForm_WithEmptyBasePath(t *testing.T) { func TestFormsManager_CreateSecretForm_WithNilCallback(t *testing.T) { cfg := &config.Config{} - vaultMgr := &vault.Manager{} logger := logrus.New() + interactor, err := backend.NewInteractor(logger, cfg) + require.NoError(t, err) app := tview.NewApplication() - fm := NewFormsManager(cfg, vaultMgr, logger, app) + fm := NewFormsManager(cfg, interactor, logger, app) // Should not panic with nil callback primitive := fm.CreateSecretForm("/secret/base", nil) @@ -58,11 +62,12 @@ func TestFormsManager_CreateSecretForm_WithNilCallback(t *testing.T) { func TestFormsManager_CreateSecretForm_PathInitialization(t *testing.T) { cfg := &config.Config{} - vaultMgr := &vault.Manager{} logger := logrus.New() + interactor, err := backend.NewInteractor(logger, cfg) + require.NoError(t, err) app := tview.NewApplication() - fm := NewFormsManager(cfg, vaultMgr, logger, app) + fm := NewFormsManager(cfg, interactor, logger, app) basePath := "/secret/myapp" primitive := fm.CreateSecretForm(basePath, func() {}) @@ -75,11 +80,12 @@ func TestFormsManager_CreateSecretForm_PathInitialization(t *testing.T) { func TestFormsManager_CreateSecretForm_ReturnsFlexContainer(t *testing.T) { cfg := &config.Config{} - vaultMgr := &vault.Manager{} logger := logrus.New() + interactor, err := backend.NewInteractor(logger, cfg) + require.NoError(t, err) app := tview.NewApplication() - fm := NewFormsManager(cfg, vaultMgr, logger, app) + fm := NewFormsManager(cfg, interactor, logger, app) primitive := fm.CreateSecretForm("/secret/test", func() {}) @@ -91,11 +97,12 @@ func TestFormsManager_CreateSecretForm_ReturnsFlexContainer(t *testing.T) { func TestFormsManager_CreateSecretForm_WithTrailingSlash(t *testing.T) { cfg := &config.Config{} - vaultMgr := &vault.Manager{} logger := logrus.New() + interactor, err := backend.NewInteractor(logger, cfg) + require.NoError(t, err) app := tview.NewApplication() - fm := NewFormsManager(cfg, vaultMgr, logger, app) + fm := NewFormsManager(cfg, interactor, logger, app) primitive := fm.CreateSecretForm("/secret/base/", func() {}) @@ -104,11 +111,12 @@ func TestFormsManager_CreateSecretForm_WithTrailingSlash(t *testing.T) { func TestFormsManager_CreateSecretForm_WithoutTrailingSlash(t *testing.T) { cfg := &config.Config{} - vaultMgr := &vault.Manager{} logger := logrus.New() + interactor, err := backend.NewInteractor(logger, cfg) + require.NoError(t, err) app := tview.NewApplication() - fm := NewFormsManager(cfg, vaultMgr, logger, app) + fm := NewFormsManager(cfg, interactor, logger, app) primitive := fm.CreateSecretForm("/secret/base", func() {}) @@ -117,11 +125,12 @@ func TestFormsManager_CreateSecretForm_WithoutTrailingSlash(t *testing.T) { func TestFormsManager_CreateSecretForm_MultipleInvocations(t *testing.T) { cfg := &config.Config{} - vaultMgr := &vault.Manager{} logger := logrus.New() + interactor, err := backend.NewInteractor(logger, cfg) + require.NoError(t, err) app := tview.NewApplication() - fm := NewFormsManager(cfg, vaultMgr, logger, app) + fm := NewFormsManager(cfg, interactor, logger, app) // Create multiple forms form1 := fm.CreateSecretForm("/secret/path1", func() {}) diff --git a/internal/ui/forms/delete_secret_test.go b/internal/ui/forms/delete_secret_test.go index 6888b52..5bdb762 100644 --- a/internal/ui/forms/delete_secret_test.go +++ b/internal/ui/forms/delete_secret_test.go @@ -4,19 +4,22 @@ import ( "testing" "github.com/rivo/tview" + "github.com/rvolykh/vui/internal/backend" "github.com/rvolykh/vui/internal/config" - "github.com/rvolykh/vui/internal/vault" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestFormsManager_DeleteSecretForm(t *testing.T) { cfg := &config.Config{} - vaultMgr := &vault.Manager{} logger := logrus.New() + interactor, err := backend.NewInteractor(logger, cfg) + require.NoError(t, err) + app := tview.NewApplication() - fm := NewFormsManager(cfg, vaultMgr, logger, app) + fm := NewFormsManager(cfg, interactor, logger, app) callbackCalled := false callback := func() { @@ -31,11 +34,12 @@ func TestFormsManager_DeleteSecretForm(t *testing.T) { func TestFormsManager_DeleteSecretForm_ReturnsModal(t *testing.T) { cfg := &config.Config{} - vaultMgr := &vault.Manager{} logger := logrus.New() + interactor, err := backend.NewInteractor(logger, cfg) + require.NoError(t, err) app := tview.NewApplication() - fm := NewFormsManager(cfg, vaultMgr, logger, app) + fm := NewFormsManager(cfg, interactor, logger, app) primitive := fm.DeleteSecretForm("/secret/test", func() {}) @@ -47,11 +51,12 @@ func TestFormsManager_DeleteSecretForm_ReturnsModal(t *testing.T) { func TestFormsManager_DeleteSecretForm_WithNilCallback(t *testing.T) { cfg := &config.Config{} - vaultMgr := &vault.Manager{} logger := logrus.New() + interactor, err := backend.NewInteractor(logger, cfg) + require.NoError(t, err) app := tview.NewApplication() - fm := NewFormsManager(cfg, vaultMgr, logger, app) + fm := NewFormsManager(cfg, interactor, logger, app) // Should not panic with nil callback primitive := fm.DeleteSecretForm("/secret/test", nil) @@ -61,11 +66,13 @@ func TestFormsManager_DeleteSecretForm_WithNilCallback(t *testing.T) { func TestFormsManager_DeleteSecretForm_WithEmptyPath(t *testing.T) { cfg := &config.Config{} - vaultMgr := &vault.Manager{} logger := logrus.New() + interactor, err := backend.NewInteractor(logger, cfg) + require.NoError(t, err) + app := tview.NewApplication() - fm := NewFormsManager(cfg, vaultMgr, logger, app) + fm := NewFormsManager(cfg, interactor, logger, app) primitive := fm.DeleteSecretForm("", func() {}) @@ -74,11 +81,12 @@ func TestFormsManager_DeleteSecretForm_WithEmptyPath(t *testing.T) { func TestFormsManager_DeleteSecretForm_WithLongPath(t *testing.T) { cfg := &config.Config{} - vaultMgr := &vault.Manager{} logger := logrus.New() + interactor, err := backend.NewInteractor(logger, cfg) + require.NoError(t, err) app := tview.NewApplication() - fm := NewFormsManager(cfg, vaultMgr, logger, app) + fm := NewFormsManager(cfg, interactor, logger, app) longPath := "/secret/very/long/path/to/secret/that/needs/to/be/deleted" primitive := fm.DeleteSecretForm(longPath, func() {}) @@ -88,11 +96,12 @@ func TestFormsManager_DeleteSecretForm_WithLongPath(t *testing.T) { func TestFormsManager_DeleteSecretForm_MultipleInvocations(t *testing.T) { cfg := &config.Config{} - vaultMgr := &vault.Manager{} logger := logrus.New() + interactor, err := backend.NewInteractor(logger, cfg) + require.NoError(t, err) app := tview.NewApplication() - fm := NewFormsManager(cfg, vaultMgr, logger, app) + fm := NewFormsManager(cfg, interactor, logger, app) // Create multiple delete forms form1 := fm.DeleteSecretForm("/secret/path1", func() {}) @@ -105,11 +114,12 @@ func TestFormsManager_DeleteSecretForm_MultipleInvocations(t *testing.T) { func TestFormsManager_DeleteSecretForm_WithSpecialCharactersInPath(t *testing.T) { cfg := &config.Config{} - vaultMgr := &vault.Manager{} logger := logrus.New() + interactor, err := backend.NewInteractor(logger, cfg) + require.NoError(t, err) app := tview.NewApplication() - fm := NewFormsManager(cfg, vaultMgr, logger, app) + fm := NewFormsManager(cfg, interactor, logger, app) specialPath := "/secret/test-secret_123/item.name" primitive := fm.DeleteSecretForm(specialPath, func() {}) diff --git a/internal/ui/forms/edit_secret_test.go b/internal/ui/forms/edit_secret_test.go index c90450a..846ed0d 100644 --- a/internal/ui/forms/edit_secret_test.go +++ b/internal/ui/forms/edit_secret_test.go @@ -4,19 +4,21 @@ import ( "testing" "github.com/rivo/tview" + "github.com/rvolykh/vui/internal/backend" "github.com/rvolykh/vui/internal/config" - "github.com/rvolykh/vui/internal/vault" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestFormsManager_EditSecretForm_WithGetSecretError(t *testing.T) { cfg := &config.Config{} - vaultMgr := &vault.Manager{} logger := logrus.New() + interactor, err := backend.NewInteractor(logger, cfg) + require.NoError(t, err) app := tview.NewApplication() - fm := NewFormsManager(cfg, vaultMgr, logger, app) + fm := NewFormsManager(cfg, interactor, logger, app) callback := func() { // Callback for testing @@ -35,11 +37,12 @@ func TestFormsManager_EditSecretForm_WithGetSecretError(t *testing.T) { func TestFormsManager_EditSecretForm_WithEmptyPath(t *testing.T) { cfg := &config.Config{} - vaultMgr := &vault.Manager{} logger := logrus.New() + interactor, err := backend.NewInteractor(logger, cfg) + require.NoError(t, err) app := tview.NewApplication() - fm := NewFormsManager(cfg, vaultMgr, logger, app) + fm := NewFormsManager(cfg, interactor, logger, app) // Empty path should cause GetSecret to fail primitive := fm.EditSecretForm("", func() {}) @@ -52,11 +55,12 @@ func TestFormsManager_EditSecretForm_WithEmptyPath(t *testing.T) { func TestFormsManager_EditSecretForm_WithNilCallback(t *testing.T) { cfg := &config.Config{} - vaultMgr := &vault.Manager{} logger := logrus.New() + interactor, err := backend.NewInteractor(logger, cfg) + require.NoError(t, err) app := tview.NewApplication() - fm := NewFormsManager(cfg, vaultMgr, logger, app) + fm := NewFormsManager(cfg, interactor, logger, app) // Should not panic with nil callback primitive := fm.EditSecretForm("/secret/test", nil) @@ -66,11 +70,12 @@ func TestFormsManager_EditSecretForm_WithNilCallback(t *testing.T) { func TestFormsManager_EditSecretForm_MultipleInvocations(t *testing.T) { cfg := &config.Config{} - vaultMgr := &vault.Manager{} logger := logrus.New() + interactor, err := backend.NewInteractor(logger, cfg) + require.NoError(t, err) app := tview.NewApplication() - fm := NewFormsManager(cfg, vaultMgr, logger, app) + fm := NewFormsManager(cfg, interactor, logger, app) // Create multiple edit forms form1 := fm.EditSecretForm("/secret/path1", func() {}) @@ -82,11 +87,12 @@ func TestFormsManager_EditSecretForm_MultipleInvocations(t *testing.T) { func TestFormsManager_EditSecretForm_WithLongPath(t *testing.T) { cfg := &config.Config{} - vaultMgr := &vault.Manager{} logger := logrus.New() + interactor, err := backend.NewInteractor(logger, cfg) + require.NoError(t, err) app := tview.NewApplication() - fm := NewFormsManager(cfg, vaultMgr, logger, app) + fm := NewFormsManager(cfg, interactor, logger, app) longPath := "/secret/very/long/path/to/secret/that/needs/to/be/edited" primitive := fm.EditSecretForm(longPath, func() {}) @@ -96,11 +102,12 @@ func TestFormsManager_EditSecretForm_WithLongPath(t *testing.T) { func TestFormsManager_EditSecretForm_WithSpecialCharactersInPath(t *testing.T) { cfg := &config.Config{} - vaultMgr := &vault.Manager{} logger := logrus.New() + interactor, err := backend.NewInteractor(logger, cfg) + require.NoError(t, err) app := tview.NewApplication() - fm := NewFormsManager(cfg, vaultMgr, logger, app) + fm := NewFormsManager(cfg, interactor, logger, app) specialPath := "/secret/test-secret_123/item.name" primitive := fm.EditSecretForm(specialPath, func() {}) @@ -110,11 +117,12 @@ func TestFormsManager_EditSecretForm_WithSpecialCharactersInPath(t *testing.T) { func TestFormsManager_EditSecretForm_ReturnsErrorModalOnFailure(t *testing.T) { cfg := &config.Config{} - vaultMgr := &vault.Manager{} logger := logrus.New() + interactor, err := backend.NewInteractor(logger, cfg) + require.NoError(t, err) app := tview.NewApplication() - fm := NewFormsManager(cfg, vaultMgr, logger, app) + fm := NewFormsManager(cfg, interactor, logger, app) // Since vault is not initialized, GetSecret will fail primitive := fm.EditSecretForm("/secret/test", func() {}) diff --git a/internal/ui/forms/manager.go b/internal/ui/forms/manager.go index b88f0a4..7569790 100644 --- a/internal/ui/forms/manager.go +++ b/internal/ui/forms/manager.go @@ -2,29 +2,29 @@ package forms import ( "github.com/rivo/tview" + "github.com/rvolykh/vui/internal/backend" "github.com/rvolykh/vui/internal/config" "github.com/rvolykh/vui/internal/ui/handlers" - "github.com/rvolykh/vui/internal/vault" "github.com/sirupsen/logrus" ) // FormsManager manages input forms for the application type FormsManager struct { config *config.Config - vaultMgr *vault.Manager + interactor backend.Interactor logger *logrus.Logger app *tview.Application secretHandler *handlers.SecretHandler } // NewFormsManager creates a new forms manager -func NewFormsManager(config *config.Config, vaultMgr *vault.Manager, logger *logrus.Logger, app *tview.Application) *FormsManager { +func NewFormsManager(config *config.Config, interactor backend.Interactor, logger *logrus.Logger, app *tview.Application) *FormsManager { return &FormsManager{ config: config, - vaultMgr: vaultMgr, + interactor: interactor, logger: logger, app: app, - secretHandler: handlers.NewSecretHandler(vaultMgr), + secretHandler: handlers.NewSecretHandler(interactor), } } diff --git a/internal/ui/forms/manager_test.go b/internal/ui/forms/manager_test.go index 0916e16..521da3d 100644 --- a/internal/ui/forms/manager_test.go +++ b/internal/ui/forms/manager_test.go @@ -4,23 +4,25 @@ import ( "testing" "github.com/rivo/tview" + "github.com/rvolykh/vui/internal/backend" "github.com/rvolykh/vui/internal/config" - "github.com/rvolykh/vui/internal/vault" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestNewFormsManager(t *testing.T) { cfg := &config.Config{} - vaultMgr := &vault.Manager{} logger := logrus.New() + interactor, err := backend.NewInteractor(logger, cfg) + require.NoError(t, err) app := tview.NewApplication() - fm := NewFormsManager(cfg, vaultMgr, logger, app) + fm := NewFormsManager(cfg, interactor, logger, app) assert.NotNil(t, fm) assert.Equal(t, cfg, fm.config) - assert.Equal(t, vaultMgr, fm.vaultMgr) + assert.Equal(t, interactor, fm.interactor) assert.Equal(t, logger, fm.logger) assert.Equal(t, app, fm.app) assert.NotNil(t, fm.secretHandler) @@ -28,11 +30,12 @@ func TestNewFormsManager(t *testing.T) { func TestFormsManager_GetSecretHandler(t *testing.T) { cfg := &config.Config{} - vaultMgr := &vault.Manager{} logger := logrus.New() + interactor, err := backend.NewInteractor(logger, cfg) + require.NoError(t, err) app := tview.NewApplication() - fm := NewFormsManager(cfg, vaultMgr, logger, app) + fm := NewFormsManager(cfg, interactor, logger, app) handler := fm.GetSecretHandler() assert.NotNil(t, handler) @@ -41,11 +44,12 @@ func TestFormsManager_GetSecretHandler(t *testing.T) { func TestFormsManager_GetSecretHandler_NotNil(t *testing.T) { cfg := &config.Config{} - vaultMgr := &vault.Manager{} logger := logrus.New() + interactor, err := backend.NewInteractor(logger, cfg) + require.NoError(t, err) app := tview.NewApplication() - fm := NewFormsManager(cfg, vaultMgr, logger, app) + fm := NewFormsManager(cfg, interactor, logger, app) // Get handler multiple times should return the same instance handler1 := fm.GetSecretHandler() @@ -53,51 +57,3 @@ func TestFormsManager_GetSecretHandler_NotNil(t *testing.T) { assert.Equal(t, handler1, handler2) } - -func TestFormsManager_WithNilLogger(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - app := tview.NewApplication() - - // Should not panic with nil logger - fm := NewFormsManager(cfg, vaultMgr, nil, app) - - assert.NotNil(t, fm) - assert.Nil(t, fm.logger) -} - -func TestFormsManager_WithNilApp(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - - // Should not panic with nil app - fm := NewFormsManager(cfg, vaultMgr, logger, nil) - - assert.NotNil(t, fm) - assert.Nil(t, fm.app) -} - -func TestFormsManager_WithNilConfig(t *testing.T) { - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() - - // Should not panic with nil config - fm := NewFormsManager(nil, vaultMgr, logger, app) - - assert.NotNil(t, fm) - assert.Nil(t, fm.config) -} - -func TestFormsManager_WithNilVaultManager(t *testing.T) { - cfg := &config.Config{} - logger := logrus.New() - app := tview.NewApplication() - - // Should not panic with nil vault manager - fm := NewFormsManager(cfg, nil, logger, app) - - assert.NotNil(t, fm) - assert.Nil(t, fm.vaultMgr) -} diff --git a/internal/ui/handlers/clipboard_handlers.go b/internal/ui/handlers/clipboard_handlers.go index 6774a44..3c002cb 100644 --- a/internal/ui/handlers/clipboard_handlers.go +++ b/internal/ui/handlers/clipboard_handlers.go @@ -5,7 +5,8 @@ import ( "strings" "github.com/atotto/clipboard" - "github.com/rvolykh/vui/internal/vault" + "github.com/rvolykh/vui/internal/backend" + "github.com/rvolykh/vui/internal/models" ) // ClipboardWriter is an interface for writing to clipboard (for testing) @@ -23,28 +24,28 @@ func (rc *RealClipboard) WriteAll(text string) error { // ClipboardHandler handles clipboard operations type ClipboardHandler struct { - vaultMgr *vault.Manager - writer ClipboardWriter + interactor backend.Interactor + writer ClipboardWriter } // NewClipboardHandler creates a new clipboard handler with the real clipboard -func NewClipboardHandler(vaultMgr *vault.Manager) *ClipboardHandler { +func NewClipboardHandler(interactor backend.Interactor) *ClipboardHandler { return &ClipboardHandler{ - vaultMgr: vaultMgr, - writer: &RealClipboard{}, + interactor: interactor, + writer: &RealClipboard{}, } } // NewClipboardHandlerWithWriter creates a clipboard handler with a custom writer (for testing) -func NewClipboardHandlerWithWriter(vaultMgr *vault.Manager, writer ClipboardWriter) *ClipboardHandler { +func NewClipboardHandlerWithWriter(interactor backend.Interactor, writer ClipboardWriter) *ClipboardHandler { return &ClipboardHandler{ - vaultMgr: vaultMgr, - writer: writer, + interactor: interactor, + writer: writer, } } // CopyKeyValue copies a specific key's value from a secret to clipboard -func (ch *ClipboardHandler) CopyKeyValue(secret *vault.SecretNode, key string) error { +func (ch *ClipboardHandler) CopyKeyValue(secret *models.SecretNode, key string) error { if secret == nil { return fmt.Errorf("secret is nil") } @@ -55,12 +56,12 @@ func (ch *ClipboardHandler) CopyKeyValue(secret *vault.SecretNode, key string) e // Get the full secret data if not already loaded if secret.Data == nil { - secretsManager, err := ch.vaultMgr.GetSecretsManager() + secretsInteractor, err := ch.interactor.Secrets() if err != nil { return fmt.Errorf("failed to get secrets manager: %w", err) } - fullSecret, err := secretsManager.GetSecret(secret.Path) + fullSecret, err := secretsInteractor.GetSecret(secret.Path) if err != nil { return fmt.Errorf("failed to get secret: %w", err) } @@ -83,19 +84,19 @@ func (ch *ClipboardHandler) CopyKeyValue(secret *vault.SecretNode, key string) e // CopySecretValues copies all values from a secret to clipboard // If there's only one value, copies just that value // If there are multiple values, copies them one per line -func (ch *ClipboardHandler) CopySecretValues(secret *vault.SecretNode) error { +func (ch *ClipboardHandler) CopySecretValues(secret *models.SecretNode) error { if secret == nil { return fmt.Errorf("secret is nil") } // Get the full secret data if not already loaded if secret.Data == nil { - secretsManager, err := ch.vaultMgr.GetSecretsManager() + secretsInteractor, err := ch.interactor.Secrets() if err != nil { return fmt.Errorf("failed to get secrets manager: %w", err) } - fullSecret, err := secretsManager.GetSecret(secret.Path) + fullSecret, err := secretsInteractor.GetSecret(secret.Path) if err != nil { return fmt.Errorf("failed to get secret: %w", err) } diff --git a/internal/ui/handlers/clipboard_handlers_test.go b/internal/ui/handlers/clipboard_handlers_test.go index c41bfda..f0c3f10 100644 --- a/internal/ui/handlers/clipboard_handlers_test.go +++ b/internal/ui/handlers/clipboard_handlers_test.go @@ -3,7 +3,7 @@ package handlers import ( "testing" - "github.com/rvolykh/vui/internal/vault" + "github.com/rvolykh/vui/internal/models" "github.com/stretchr/testify/assert" ) @@ -40,7 +40,7 @@ func TestClipboardHandler_CopyKeyValue(t *testing.T) { tests := []struct { name string - secret *vault.SecretNode + secret *models.SecretNode key string expectError bool expectedVal string @@ -53,7 +53,7 @@ func TestClipboardHandler_CopyKeyValue(t *testing.T) { }, { name: "empty key", - secret: &vault.SecretNode{ + secret: &models.SecretNode{ Name: "test", Path: "secrets/test", Data: map[string]interface{}{ @@ -65,7 +65,7 @@ func TestClipboardHandler_CopyKeyValue(t *testing.T) { }, { name: "key not found", - secret: &vault.SecretNode{ + secret: &models.SecretNode{ Name: "test", Path: "secrets/test", Data: map[string]interface{}{ @@ -77,7 +77,7 @@ func TestClipboardHandler_CopyKeyValue(t *testing.T) { }, { name: "successful copy", - secret: &vault.SecretNode{ + secret: &models.SecretNode{ Name: "test", Path: "secrets/test", Data: map[string]interface{}{ @@ -111,7 +111,7 @@ func TestClipboardHandler_CopySecretValues(t *testing.T) { tests := []struct { name string - secret *vault.SecretNode + secret *models.SecretNode expectError bool validate func(t *testing.T, content string) }{ @@ -122,7 +122,7 @@ func TestClipboardHandler_CopySecretValues(t *testing.T) { }, { name: "empty data", - secret: &vault.SecretNode{ + secret: &models.SecretNode{ Name: "test", Path: "secrets/test", Data: map[string]interface{}{}, @@ -131,7 +131,7 @@ func TestClipboardHandler_CopySecretValues(t *testing.T) { }, { name: "single value", - secret: &vault.SecretNode{ + secret: &models.SecretNode{ Name: "test", Path: "secrets/test", Data: map[string]interface{}{ @@ -145,7 +145,7 @@ func TestClipboardHandler_CopySecretValues(t *testing.T) { }, { name: "multiple values", - secret: &vault.SecretNode{ + secret: &models.SecretNode{ Name: "test", Path: "secrets/test", Data: map[string]interface{}{ diff --git a/internal/ui/handlers/secret_handlers.go b/internal/ui/handlers/secret_handlers.go index 275d5ac..0aeb429 100644 --- a/internal/ui/handlers/secret_handlers.go +++ b/internal/ui/handlers/secret_handlers.go @@ -3,18 +3,19 @@ package handlers import ( "fmt" - "github.com/rvolykh/vui/internal/vault" + "github.com/rvolykh/vui/internal/backend" + "github.com/rvolykh/vui/internal/models" ) // SecretHandler handles secret operations type SecretHandler struct { - vaultMgr *vault.Manager + interactor backend.Interactor } // NewSecretHandler creates a new secret handler -func NewSecretHandler(vaultMgr *vault.Manager) *SecretHandler { +func NewSecretHandler(interactor backend.Interactor) *SecretHandler { return &SecretHandler{ - vaultMgr: vaultMgr, + interactor: interactor, } } @@ -28,12 +29,12 @@ func (sh *SecretHandler) CreateSecret(path string, data map[string]interface{}) return fmt.Errorf("at least one key-value pair is required") } - secretsManager, err := sh.vaultMgr.GetSecretsManager() + secretsInteractor, err := sh.interactor.Secrets() if err != nil { return fmt.Errorf("failed to get secrets manager: %w", err) } - if err := secretsManager.CreateSecret(path, data); err != nil { + if err := secretsInteractor.CreateSecret(path, data); err != nil { return fmt.Errorf("failed to create secret: %w", err) } @@ -50,12 +51,12 @@ func (sh *SecretHandler) UpdateSecret(path string, data map[string]interface{}) return fmt.Errorf("at least one key-value pair is required") } - secretsManager, err := sh.vaultMgr.GetSecretsManager() + secretsInteractor, err := sh.interactor.Secrets() if err != nil { return fmt.Errorf("failed to get secrets manager: %w", err) } - if err := secretsManager.UpdateSecret(path, data); err != nil { + if err := secretsInteractor.UpdateSecret(path, data); err != nil { return fmt.Errorf("failed to update secret: %w", err) } @@ -68,12 +69,12 @@ func (sh *SecretHandler) DeleteSecret(path string) error { return fmt.Errorf("secret path is required") } - secretsManager, err := sh.vaultMgr.GetSecretsManager() + secretsInteractor, err := sh.interactor.Secrets() if err != nil { return fmt.Errorf("failed to get secrets manager: %w", err) } - if err := secretsManager.DeleteSecret(path); err != nil { + if err := secretsInteractor.DeleteSecret(path); err != nil { return fmt.Errorf("failed to delete secret: %w", err) } @@ -81,17 +82,17 @@ func (sh *SecretHandler) DeleteSecret(path string) error { } // GetSecret retrieves a secret at the given path -func (sh *SecretHandler) GetSecret(path string) (*vault.SecretNode, error) { +func (sh *SecretHandler) GetSecret(path string) (*models.SecretNode, error) { if path == "" { return nil, fmt.Errorf("secret path is required") } - secretsManager, err := sh.vaultMgr.GetSecretsManager() + secretsInteractor, err := sh.interactor.Secrets() if err != nil { return nil, fmt.Errorf("failed to get secrets manager: %w", err) } - secret, err := secretsManager.GetSecret(path) + secret, err := secretsInteractor.GetSecret(path) if err != nil { return nil, fmt.Errorf("failed to get secret: %w", err) } @@ -100,13 +101,13 @@ func (sh *SecretHandler) GetSecret(path string) (*vault.SecretNode, error) { } // ListSecrets lists all secrets at the given path -func (sh *SecretHandler) ListSecrets(path string) ([]*vault.SecretNode, error) { - secretsManager, err := sh.vaultMgr.GetSecretsManager() +func (sh *SecretHandler) ListSecrets(path string) ([]*models.SecretNode, error) { + secretsInteractor, err := sh.interactor.Secrets() if err != nil { return nil, fmt.Errorf("failed to get secrets manager: %w", err) } - secrets, err := secretsManager.ListSecrets(path) + secrets, err := secretsInteractor.ListSecrets(path) if err != nil { return nil, fmt.Errorf("failed to list secrets: %w", err) } diff --git a/internal/ui/layout.go b/internal/ui/layout.go index 164cf85..88899d0 100644 --- a/internal/ui/layout.go +++ b/internal/ui/layout.go @@ -4,17 +4,18 @@ import ( "fmt" "github.com/rivo/tview" + "github.com/rvolykh/vui/internal/backend" "github.com/rvolykh/vui/internal/config" + "github.com/rvolykh/vui/internal/models" "github.com/rvolykh/vui/internal/ui/common" "github.com/rvolykh/vui/internal/ui/panels" - "github.com/rvolykh/vui/internal/vault" "github.com/sirupsen/logrus" ) // Layout represents the main application layout type Layout struct { config *config.Config - vaultMgr *vault.Manager + interactor backend.Interactor root *tview.Flex helpPanel *panels.SecretsTitle treePanel *panels.SecretsTree @@ -27,11 +28,11 @@ type Layout struct { } // NewLayout creates a new layout -func NewLayout(config *config.Config, vaultMgr *vault.Manager, logger *logrus.Logger) *Layout { +func NewLayout(config *config.Config, interactor backend.Interactor, logger *logrus.Logger) *Layout { return &Layout{ - config: config, - vaultMgr: vaultMgr, - logger: logger, + config: config, + interactor: interactor, + logger: logger, } } @@ -45,31 +46,31 @@ func (l *Layout) Initialize() error { l.logger.Info("Initializing UI layout") // Create the help panel - l.helpPanel = panels.NewSecretsTitle(l.config, l.vaultMgr, l.logger) + l.helpPanel = panels.NewSecretsTitle(l.config, l.interactor, l.logger) if err := l.helpPanel.Initialize(); err != nil { return fmt.Errorf("failed to initialize help panel: %w", err) } // Create the tree panel - l.treePanel = panels.NewSecretsTree(l.config, l.vaultMgr, l.logger, l.app) + l.treePanel = panels.NewSecretsTree(l.config, l.interactor, l.logger, l.app) if err := l.treePanel.Initialize(); err != nil { return fmt.Errorf("failed to initialize tree panel: %w", err) } // Create the metadata panel - l.metadataPanel = panels.NewSecretsMetadata(l.config, l.vaultMgr, l.logger) + l.metadataPanel = panels.NewSecretsMetadata(l.config, l.interactor, l.logger) if err := l.metadataPanel.Initialize(); err != nil { return fmt.Errorf("failed to initialize metadata panel: %w", err) } // Create the value panel - l.valuePanel = panels.NewSecretsValue(l.config, l.vaultMgr, l.logger) + l.valuePanel = panels.NewSecretsValue(l.config, l.interactor, l.logger) if err := l.valuePanel.Initialize(); err != nil { return fmt.Errorf("failed to initialize value panel: %w", err) } // Create the status bar - l.statusBar = panels.NewSecretsStatus(l.config, l.vaultMgr, l.logger) + l.statusBar = panels.NewSecretsStatus(l.config, l.interactor, l.logger) if err := l.statusBar.Initialize(); err != nil { return fmt.Errorf("failed to initialize status bar: %w", err) } @@ -117,20 +118,20 @@ func (l *Layout) setupLayout() { // setupEventHandlers sets up event handlers between components func (l *Layout) setupEventHandlers() { // When a tree item is selected, update the metadata and value panels - l.treePanel.SetSelectionHandler(func(node *vault.SecretNode, selectedKey string) { + l.treePanel.SetSelectionHandler(func(node *models.SecretNode, selectedKey string) { l.logger.Infof("Selection changed: path=%s, isSecret=%v, key=%s", node.Path, node.IsSecret, selectedKey) if selectedKey != "" { // A specific key within a secret is selected // We need to fetch the full secret data to show the key's value - secretsManager, err := l.vaultMgr.GetSecretsManager() + secretsInteractor, err := l.interactor.Secrets() if err != nil { l.logger.Errorf("Failed to get secrets manager: %v", err) return } // Get the full secret data - secret, err := secretsManager.GetSecret(node.Path) + secret, err := secretsInteractor.GetSecret(node.Path) if err != nil { l.logger.Errorf("Failed to get secret: %v", err) return @@ -141,7 +142,7 @@ func (l *Layout) setupEventHandlers() { l.statusBar.UpdateSelection(fmt.Sprintf("%s/%s", node.Path, selectedKey), true) } else if node.IsSecret { // A secret is selected (but not a specific key) - secretsManager, err := l.vaultMgr.GetSecretsManager() + secretsManager, err := l.interactor.Secrets() if err != nil { l.logger.Errorf("Failed to get secrets manager: %v", err) return @@ -159,17 +160,17 @@ func (l *Layout) setupEventHandlers() { l.statusBar.UpdateSelection(node.Path, true) } else { // A directory is selected - secretsManager, err := l.vaultMgr.GetSecretsManager() + secretsInteractor, err := l.interactor.Secrets() if err != nil { l.logger.Errorf("Failed to get secrets manager: %v", err) return } // Get directory contents to count items - secrets, err := secretsManager.ListSecrets(node.Path) + secrets, err := secretsInteractor.ListSecrets(node.Path) if err != nil { l.logger.Errorf("Failed to list secrets: %v", err) - secrets = []*vault.SecretNode{} + secrets = []*models.SecretNode{} } l.metadataPanel.ShowDirectory(node.Path, len(secrets)) diff --git a/internal/ui/layout_test.go b/internal/ui/layout_test.go index 7a0d286..959255d 100644 --- a/internal/ui/layout_test.go +++ b/internal/ui/layout_test.go @@ -3,8 +3,8 @@ package ui import ( "testing" + "github.com/rvolykh/vui/internal/backend" "github.com/rvolykh/vui/internal/config" - "github.com/rvolykh/vui/internal/vault" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" ) @@ -21,14 +21,14 @@ func TestLayoutOfflineMode(t *testing.T) { } logger := logrus.New() - // Create test vault manager - vaultMgr, err := vault.NewManager(cfg, logger) + // Create test interactor + interactor, err := backend.NewInteractor(logger, cfg) if err != nil { t.Skip("Skipping test - no vault server available") } // Create layout - layout := NewLayout(cfg, vaultMgr, logger) + layout := NewLayout(cfg, interactor, logger) // Test initialization (should work even without vault connection) err = layout.Initialize() @@ -50,12 +50,12 @@ func TestLayoutStructure(t *testing.T) { } logger := logrus.New() - vaultMgr, err := vault.NewManager(cfg, logger) + interactor, err := backend.NewInteractor(logger, cfg) if err != nil { t.Skip("Skipping test - no vault server available") } - layout := NewLayout(cfg, vaultMgr, logger) + layout := NewLayout(cfg, interactor, logger) // Initialize the layout err = layout.Initialize() diff --git a/internal/ui/panels/dependencies_test.go b/internal/ui/panels/dependencies_test.go index e309656..3d8bc2a 100644 --- a/internal/ui/panels/dependencies_test.go +++ b/internal/ui/panels/dependencies_test.go @@ -1,7 +1,39 @@ package panels +import ( + "testing" + + "github.com/rivo/tview" + "github.com/rvolykh/vui/internal/backend" + "github.com/rvolykh/vui/internal/config" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/require" +) + // Mock dialog service for testing type mockDialogService struct{} func (m *mockDialogService) ShowInfo(title, message string, callback func()) {} func (m *mockDialogService) ShowError(message string, callback func()) {} + +type Fixtures struct { + cfg *config.Config + logger *logrus.Logger + interactor backend.Interactor + app *tview.Application +} + +func WithFixtures(t *testing.T) Fixtures { + cfg := &config.Config{} + logger := logrus.New() + interactor, err := backend.NewInteractor(logger, cfg) + require.NoError(t, err) + app := tview.NewApplication() + + return Fixtures{ + cfg: cfg, + logger: logger, + interactor: interactor, + app: app, + } +} diff --git a/internal/ui/panels/profiles_table.go b/internal/ui/panels/profiles_table.go index b197d3b..b7c439a 100644 --- a/internal/ui/panels/profiles_table.go +++ b/internal/ui/panels/profiles_table.go @@ -8,15 +8,16 @@ import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + "github.com/rvolykh/vui/internal/backend" "github.com/rvolykh/vui/internal/config" - "github.com/rvolykh/vui/internal/vault" + "github.com/rvolykh/vui/internal/models" "github.com/sirupsen/logrus" ) // ProfilesTable displays vault profiles with connection status type ProfilesTable struct { config *config.Config - vaultMgr *vault.Manager + interactor backend.Interactor table *tview.Table app *tview.Application logger *logrus.Logger @@ -28,10 +29,10 @@ type ProfilesTable struct { } // NewProfilesTable creates a new vault profiles panel -func NewProfilesTable(config *config.Config, vaultMgr *vault.Manager, app *tview.Application, logger *logrus.Logger) *ProfilesTable { +func NewProfilesTable(config *config.Config, interactor backend.Interactor, app *tview.Application, logger *logrus.Logger) *ProfilesTable { return &ProfilesTable{ config: config, - vaultMgr: vaultMgr, + interactor: interactor, app: app, logger: logger, stopRefresh: make(chan struct{}), @@ -107,13 +108,13 @@ func (vpp *ProfilesTable) startRefresher() { // hasConnectingProfiles checks if there are any profiles that are in the process of connecting func (vpp *ProfilesTable) hasConnectingProfiles() bool { - vaults := vpp.vaultMgr.ListVaults() - for _, vaultName := range vaults { - status, err := vpp.vaultMgr.GetConnectionStatus(vaultName) + profiles := vpp.interactor.Profiles().ListConnections() + for _, name := range profiles { + status, err := vpp.interactor.Profiles().GetConnectionStatus(name) if err != nil { continue } - if status.Connecting { + if status.Status == models.StatusConnecting { return true } } @@ -166,11 +167,11 @@ func (vpp *ProfilesTable) loadProfiles() error { vpp.table.Clear() // Get available vaults - vaults := vpp.vaultMgr.ListVaults() + profiles := vpp.interactor.Profiles().ListConnections() // Sort vaults by name (ascending) - sort.Strings(vaults) - vpp.vaultNames = vaults + sort.Strings(profiles) + vpp.vaultNames = profiles // Create header row headers := []string{"Name", "Address", "Status", "NOTE"} @@ -183,9 +184,9 @@ func (vpp *ProfilesTable) loadProfiles() error { vpp.table.SetCell(0, col, cell) } - if len(vaults) == 0 { + if len(profiles) == 0 { // Show a message when no vaults are configured - cell := tview.NewTableCell("No vault profiles configured"). + cell := tview.NewTableCell("No profiles configured"). SetAlign(tview.AlignCenter). SetExpansion(1) vpp.table.SetCell(1, 0, cell) @@ -194,16 +195,16 @@ func (vpp *ProfilesTable) loadProfiles() error { // Add each vault profile as a row row := 1 - for _, vaultName := range vaults { + for _, name := range profiles { // Get connection status - status, err := vpp.vaultMgr.GetConnectionStatus(vaultName) + status, err := vpp.interactor.Profiles().GetConnectionStatus(name) if err != nil { - vpp.logger.Warnf("Failed to get status for vault '%s': %v", vaultName, err) + vpp.logger.Warnf("Failed to get status for profile '%s': %v", name, err) continue } // Column 0: Name - nameCell := tview.NewTableCell(vaultName). + nameCell := tview.NewTableCell(name). SetTextColor(tcell.ColorWhite). SetAlign(tview.AlignLeft) vpp.table.SetCell(row, 0, nameCell) @@ -252,15 +253,13 @@ func (vpp *ProfilesTable) loadProfiles() error { } // formatStatus formats the status text and color -func (vpp *ProfilesTable) formatStatus(status *vault.ConnectionStatus) (string, tcell.Color) { - if status.Connecting { +func (vpp *ProfilesTable) formatStatus(status *models.ConnectionStatus) (string, tcell.Color) { + if status.Status == models.StatusConnecting { return "⏳ Connecting", tcell.ColorYellow - } else if status.Connected { - if status.Sealed { - return "🔒 Sealed", tcell.ColorOrange - } else { - return "✅ Connected", tcell.ColorGreen - } + } else if status.Status == models.StatusConnected { + return "✅ Connected", tcell.ColorGreen + } else if status.Status == models.StatusSealed { + return "🔒 Sealed", tcell.ColorOrange } else { return "❌ Disconnected", tcell.ColorRed } @@ -281,29 +280,29 @@ func (vpp *ProfilesTable) switchToSelectedVault() { } // switchToVault switches to a specific vault -func (vpp *ProfilesTable) switchToVault(vaultName string) { - if err := vpp.vaultMgr.SwitchVault(vaultName); err != nil { - vpp.logger.Errorf("Failed to switch to vault '%s': %v", vaultName, err) +func (vpp *ProfilesTable) switchToVault(name string) { + if err := vpp.interactor.Profiles().SwitchProfile(name); err != nil { + vpp.logger.Errorf("Failed to switch to profile '%s': %v", name, err) // Show error dialog if callback is set if vpp.errorCallback != nil { - vpp.errorCallback(fmt.Sprintf("Failed to connect to vault '%s':\n\n%v", vaultName, err)) + vpp.errorCallback(fmt.Sprintf("Failed to connect to profile '%s':\n\n%v", name, err)) } vpp.Refresh() return } vpp.StopRefresher() - vpp.logger.Infof("Switched to vault: %s", vaultName) + vpp.logger.Infof("Switched to profile: %s", name) // Manually refresh the specific connection - vpp.vaultMgr.GetConnectionManager().RefreshConnectionStatus(vaultName) + vpp.interactor.Profiles().RefreshConnection(name) // Check the connection status - status, err := vpp.vaultMgr.GetConnectionStatus(vaultName) + status, err := vpp.interactor.Profiles().GetConnectionStatus(name) if err != nil { - vpp.logger.Errorf("Failed to get connection status for '%s': %v", vaultName, err) + vpp.logger.Errorf("Failed to get connection status for '%s': %v", name, err) if vpp.errorCallback != nil { - vpp.errorCallback(fmt.Sprintf("Failed to get connection status for '%s':\n\n%v", vaultName, err)) + vpp.errorCallback(fmt.Sprintf("Failed to get connection status for '%s':\n\n%v", name, err)) } vpp.Refresh() return @@ -311,18 +310,18 @@ func (vpp *ProfilesTable) switchToVault(vaultName string) { // Allow switching if the vault is connected, even if sealed // The user might want to unseal it or view its status - if status.Connected { + if status.Status == models.StatusConnected { // We have a connection, switch to main layout if vpp.successCallback != nil { vpp.successCallback() } } else { // Not connected yet, show error - vpp.logger.Warnf("Vault '%s' is not connected yet (status: %+v)", vaultName, status) + vpp.logger.Warnf("Profile '%s' is not connected yet (status: %+v)", name, status) if vpp.errorCallback != nil { - errorMsg := fmt.Sprintf("Cannot connect to vault '%s'", vaultName) + errorMsg := fmt.Sprintf("Cannot connect to profile '%s'", name) if status.Error != "" { - errorMsg = fmt.Sprintf("Cannot connect to vault '%s':\n\n%s", vaultName, status.Error) + errorMsg = fmt.Sprintf("Cannot connect to profile '%s':\n\n%s", name, status.Error) } vpp.errorCallback(errorMsg) } @@ -365,7 +364,7 @@ func (vpp *ProfilesTable) refreshProfiles() { // Run the reload in a goroutine to avoid blocking the UI thread go func() { // Set all connections to "Connecting" state immediately - vpp.vaultMgr.GetConnectionManager().SetAllConnecting() + vpp.interactor.Profiles().ResetConnections() // Refresh the display to show "Connecting" state vpp.app.QueueUpdateDraw(func() { @@ -373,7 +372,7 @@ func (vpp *ProfilesTable) refreshProfiles() { }) // Reload configuration from disk (this will test connections asynchronously) - if err := vpp.vaultMgr.ReloadConfiguration(); err != nil { + if err := vpp.interactor.Profiles().ReloadConfiguration(); err != nil { vpp.logger.Errorf("Failed to reload configuration: %v", err) vpp.app.QueueUpdateDraw(func() { if vpp.errorCallback != nil { diff --git a/internal/ui/panels/profiles_table_test.go b/internal/ui/panels/profiles_table_test.go index 680a9a1..862daaf 100644 --- a/internal/ui/panels/profiles_table_test.go +++ b/internal/ui/panels/profiles_table_test.go @@ -5,37 +5,28 @@ import ( "time" "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" - "github.com/rvolykh/vui/internal/config" - "github.com/rvolykh/vui/internal/vault" - "github.com/sirupsen/logrus" + "github.com/rvolykh/vui/internal/models" "github.com/stretchr/testify/assert" ) func TestNewProfilesTable(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - app := tview.NewApplication() - logger := logrus.New() + fixtures := WithFixtures(t) - pt := NewProfilesTable(cfg, vaultMgr, app, logger) + pt := NewProfilesTable(fixtures.cfg, fixtures.interactor, fixtures.app, fixtures.logger) assert.NotNil(t, pt) - assert.Equal(t, cfg, pt.config) - assert.Equal(t, vaultMgr, pt.vaultMgr) - assert.Equal(t, app, pt.app) - assert.Equal(t, logger, pt.logger) + assert.Equal(t, fixtures.cfg, pt.config) + assert.Equal(t, fixtures.interactor, pt.interactor) + assert.Equal(t, fixtures.app, pt.app) + assert.Equal(t, fixtures.logger, pt.logger) assert.NotNil(t, pt.stopRefresh) assert.Nil(t, pt.table) } func TestProfilesTable_SetSuccessCallback(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - app := tview.NewApplication() - logger := logrus.New() + fixtures := WithFixtures(t) - pt := NewProfilesTable(cfg, vaultMgr, app, logger) + pt := NewProfilesTable(fixtures.cfg, fixtures.interactor, fixtures.app, fixtures.logger) callbackCalled := false callback := func() { @@ -52,12 +43,9 @@ func TestProfilesTable_SetSuccessCallback(t *testing.T) { } func TestProfilesTable_SetSuccessCallback_Nil(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - app := tview.NewApplication() - logger := logrus.New() + fixtures := WithFixtures(t) - pt := NewProfilesTable(cfg, vaultMgr, app, logger) + pt := NewProfilesTable(fixtures.cfg, fixtures.interactor, fixtures.app, fixtures.logger) // Should not panic with nil callback pt.SetSuccessCallback(nil) @@ -66,12 +54,9 @@ func TestProfilesTable_SetSuccessCallback_Nil(t *testing.T) { } func TestProfilesTable_SetErrorCallback(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - app := tview.NewApplication() - logger := logrus.New() + fixtures := WithFixtures(t) - pt := NewProfilesTable(cfg, vaultMgr, app, logger) + pt := NewProfilesTable(fixtures.cfg, fixtures.interactor, fixtures.app, fixtures.logger) callbackCalled := false var receivedError string @@ -91,12 +76,9 @@ func TestProfilesTable_SetErrorCallback(t *testing.T) { } func TestProfilesTable_SetErrorCallback_Nil(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - app := tview.NewApplication() - logger := logrus.New() + fixtures := WithFixtures(t) - pt := NewProfilesTable(cfg, vaultMgr, app, logger) + pt := NewProfilesTable(fixtures.cfg, fixtures.interactor, fixtures.app, fixtures.logger) // Should not panic with nil callback pt.SetErrorCallback(nil) @@ -105,12 +87,9 @@ func TestProfilesTable_SetErrorCallback_Nil(t *testing.T) { } func TestProfilesTable_Initialize(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - app := tview.NewApplication() - logger := logrus.New() + fixtures := WithFixtures(t) - pt := NewProfilesTable(cfg, vaultMgr, app, logger) + pt := NewProfilesTable(fixtures.cfg, fixtures.interactor, fixtures.app, fixtures.logger) err := pt.Initialize() @@ -121,12 +100,9 @@ func TestProfilesTable_Initialize(t *testing.T) { } func TestProfilesTable_StopRefresher(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - app := tview.NewApplication() - logger := logrus.New() + fixtures := WithFixtures(t) - pt := NewProfilesTable(cfg, vaultMgr, app, logger) + pt := NewProfilesTable(fixtures.cfg, fixtures.interactor, fixtures.app, fixtures.logger) // Should not panic pt.StopRefresher() @@ -136,12 +112,9 @@ func TestProfilesTable_StopRefresher(t *testing.T) { } func TestProfilesTable_StopRefresher_WithNilChannel(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - app := tview.NewApplication() - logger := logrus.New() + fixtures := WithFixtures(t) - pt := NewProfilesTable(cfg, vaultMgr, app, logger) + pt := NewProfilesTable(fixtures.cfg, fixtures.interactor, fixtures.app, fixtures.logger) pt.stopRefresh = nil // Should not panic with nil channel @@ -149,12 +122,9 @@ func TestProfilesTable_StopRefresher_WithNilChannel(t *testing.T) { } func TestProfilesTable_StopRefresher_AfterInitialize(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - app := tview.NewApplication() - logger := logrus.New() + fixtures := WithFixtures(t) - pt := NewProfilesTable(cfg, vaultMgr, app, logger) + pt := NewProfilesTable(fixtures.cfg, fixtures.interactor, fixtures.app, fixtures.logger) pt.Initialize() // Give goroutine time to start @@ -168,12 +138,9 @@ func TestProfilesTable_StopRefresher_AfterInitialize(t *testing.T) { } func TestProfilesTable_HasConnectingProfiles_NoProfiles(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - app := tview.NewApplication() - logger := logrus.New() + fixtures := WithFixtures(t) - pt := NewProfilesTable(cfg, vaultMgr, app, logger) + pt := NewProfilesTable(fixtures.cfg, fixtures.interactor, fixtures.app, fixtures.logger) // With uninitialized vault manager, should return false result := pt.hasConnectingProfiles() @@ -181,12 +148,9 @@ func TestProfilesTable_HasConnectingProfiles_NoProfiles(t *testing.T) { } func TestProfilesTable_GetPrimitive(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - app := tview.NewApplication() - logger := logrus.New() + fixtures := WithFixtures(t) - pt := NewProfilesTable(cfg, vaultMgr, app, logger) + pt := NewProfilesTable(fixtures.cfg, fixtures.interactor, fixtures.app, fixtures.logger) pt.Initialize() primitive := pt.GetPrimitive() @@ -196,12 +160,9 @@ func TestProfilesTable_GetPrimitive(t *testing.T) { } func TestProfilesTable_GetPrimitive_BeforeInitialize(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - app := tview.NewApplication() - logger := logrus.New() + fixtures := WithFixtures(t) - pt := NewProfilesTable(cfg, vaultMgr, app, logger) + pt := NewProfilesTable(fixtures.cfg, fixtures.interactor, fixtures.app, fixtures.logger) primitive := pt.GetPrimitive() @@ -209,16 +170,12 @@ func TestProfilesTable_GetPrimitive_BeforeInitialize(t *testing.T) { } func TestProfilesTable_FormatStatus_Connecting(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - app := tview.NewApplication() - logger := logrus.New() + fixtures := WithFixtures(t) - pt := NewProfilesTable(cfg, vaultMgr, app, logger) + pt := NewProfilesTable(fixtures.cfg, fixtures.interactor, fixtures.app, fixtures.logger) - status := &vault.ConnectionStatus{ - Connecting: true, - Connected: false, + status := &models.ConnectionStatus{ + Status: models.StatusConnecting, } text, color := pt.formatStatus(status) @@ -228,17 +185,12 @@ func TestProfilesTable_FormatStatus_Connecting(t *testing.T) { } func TestProfilesTable_FormatStatus_ConnectedAndSealed(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - app := tview.NewApplication() - logger := logrus.New() + fixtures := WithFixtures(t) - pt := NewProfilesTable(cfg, vaultMgr, app, logger) + pt := NewProfilesTable(fixtures.cfg, fixtures.interactor, fixtures.app, fixtures.logger) - status := &vault.ConnectionStatus{ - Connecting: false, - Connected: true, - Sealed: true, + status := &models.ConnectionStatus{ + Status: models.StatusSealed, } text, color := pt.formatStatus(status) @@ -248,17 +200,12 @@ func TestProfilesTable_FormatStatus_ConnectedAndSealed(t *testing.T) { } func TestProfilesTable_FormatStatus_ConnectedAndUnsealed(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - app := tview.NewApplication() - logger := logrus.New() + fixtures := WithFixtures(t) - pt := NewProfilesTable(cfg, vaultMgr, app, logger) + pt := NewProfilesTable(fixtures.cfg, fixtures.interactor, fixtures.app, fixtures.logger) - status := &vault.ConnectionStatus{ - Connecting: false, - Connected: true, - Sealed: false, + status := &models.ConnectionStatus{ + Status: models.StatusConnected, } text, color := pt.formatStatus(status) @@ -268,16 +215,12 @@ func TestProfilesTable_FormatStatus_ConnectedAndUnsealed(t *testing.T) { } func TestProfilesTable_FormatStatus_Disconnected(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - app := tview.NewApplication() - logger := logrus.New() + fixtures := WithFixtures(t) - pt := NewProfilesTable(cfg, vaultMgr, app, logger) + pt := NewProfilesTable(fixtures.cfg, fixtures.interactor, fixtures.app, fixtures.logger) - status := &vault.ConnectionStatus{ - Connecting: false, - Connected: false, + status := &models.ConnectionStatus{ + Status: models.StatusDisconnected, } text, color := pt.formatStatus(status) @@ -287,12 +230,9 @@ func TestProfilesTable_FormatStatus_Disconnected(t *testing.T) { } func TestProfilesTable_LoadProfiles_NoVaults(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - app := tview.NewApplication() - logger := logrus.New() + fixtures := WithFixtures(t) - pt := NewProfilesTable(cfg, vaultMgr, app, logger) + pt := NewProfilesTable(fixtures.cfg, fixtures.interactor, fixtures.app, fixtures.logger) pt.Initialize() err := pt.loadProfiles() @@ -304,12 +244,9 @@ func TestProfilesTable_LoadProfiles_NoVaults(t *testing.T) { } func TestProfilesTable_Refresh(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - app := tview.NewApplication() - logger := logrus.New() + fixtures := WithFixtures(t) - pt := NewProfilesTable(cfg, vaultMgr, app, logger) + pt := NewProfilesTable(fixtures.cfg, fixtures.interactor, fixtures.app, fixtures.logger) pt.Initialize() // Should not panic @@ -317,12 +254,9 @@ func TestProfilesTable_Refresh(t *testing.T) { } func TestProfilesTable_Refresh_BeforeInitialize(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - app := tview.NewApplication() - logger := logrus.New() + fixtures := WithFixtures(t) - pt := NewProfilesTable(cfg, vaultMgr, app, logger) + pt := NewProfilesTable(fixtures.cfg, fixtures.interactor, fixtures.app, fixtures.logger) // Calling Refresh before Initialize will panic because table is nil // This is expected behavior - Initialize must be called first @@ -330,12 +264,9 @@ func TestProfilesTable_Refresh_BeforeInitialize(t *testing.T) { } func TestProfilesTable_SetupKeyboardNavigation(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - app := tview.NewApplication() - logger := logrus.New() + fixtures := WithFixtures(t) - pt := NewProfilesTable(cfg, vaultMgr, app, logger) + pt := NewProfilesTable(fixtures.cfg, fixtures.interactor, fixtures.app, fixtures.logger) pt.Initialize() // Verify that keyboard navigation is set up (table should have input capture) @@ -343,12 +274,9 @@ func TestProfilesTable_SetupKeyboardNavigation(t *testing.T) { } func TestProfilesTable_SwitchToSelectedVault_NoSelection(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - app := tview.NewApplication() - logger := logrus.New() + fixtures := WithFixtures(t) - pt := NewProfilesTable(cfg, vaultMgr, app, logger) + pt := NewProfilesTable(fixtures.cfg, fixtures.interactor, fixtures.app, fixtures.logger) pt.Initialize() // Should not panic with no valid selection @@ -356,12 +284,9 @@ func TestProfilesTable_SwitchToSelectedVault_NoSelection(t *testing.T) { } func TestProfilesTable_SwitchToSelectedVault_HeaderRow(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - app := tview.NewApplication() - logger := logrus.New() + fixtures := WithFixtures(t) - pt := NewProfilesTable(cfg, vaultMgr, app, logger) + pt := NewProfilesTable(fixtures.cfg, fixtures.interactor, fixtures.app, fixtures.logger) pt.Initialize() pt.table.Select(0, 0) // Select header row @@ -370,12 +295,9 @@ func TestProfilesTable_SwitchToSelectedVault_HeaderRow(t *testing.T) { } func TestProfilesTable_AddNewVault(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - app := tview.NewApplication() - logger := logrus.New() + fixtures := WithFixtures(t) - pt := NewProfilesTable(cfg, vaultMgr, app, logger) + pt := NewProfilesTable(fixtures.cfg, fixtures.interactor, fixtures.app, fixtures.logger) pt.Initialize() // Should not panic (currently not implemented) @@ -383,12 +305,9 @@ func TestProfilesTable_AddNewVault(t *testing.T) { } func TestProfilesTable_DeleteSelectedVault_NoSelection(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - app := tview.NewApplication() - logger := logrus.New() + fixtures := WithFixtures(t) - pt := NewProfilesTable(cfg, vaultMgr, app, logger) + pt := NewProfilesTable(fixtures.cfg, fixtures.interactor, fixtures.app, fixtures.logger) pt.Initialize() // Should not panic with no valid selection @@ -396,12 +315,9 @@ func TestProfilesTable_DeleteSelectedVault_NoSelection(t *testing.T) { } func TestProfilesTable_DeleteSelectedVault_HeaderRow(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - app := tview.NewApplication() - logger := logrus.New() + fixtures := WithFixtures(t) - pt := NewProfilesTable(cfg, vaultMgr, app, logger) + pt := NewProfilesTable(fixtures.cfg, fixtures.interactor, fixtures.app, fixtures.logger) pt.Initialize() pt.table.Select(0, 0) // Select header row @@ -409,57 +325,10 @@ func TestProfilesTable_DeleteSelectedVault_HeaderRow(t *testing.T) { pt.deleteSelectedVault() } -func TestProfilesTable_NewWithNilConfig(t *testing.T) { - vaultMgr := &vault.Manager{} - app := tview.NewApplication() - logger := logrus.New() - - pt := NewProfilesTable(nil, vaultMgr, app, logger) - - assert.NotNil(t, pt) - assert.Nil(t, pt.config) -} - -func TestProfilesTable_NewWithNilVaultManager(t *testing.T) { - cfg := &config.Config{} - app := tview.NewApplication() - logger := logrus.New() - - pt := NewProfilesTable(cfg, nil, app, logger) - - assert.NotNil(t, pt) - assert.Nil(t, pt.vaultMgr) -} - -func TestProfilesTable_NewWithNilApp(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - - pt := NewProfilesTable(cfg, vaultMgr, nil, logger) - - assert.NotNil(t, pt) - assert.Nil(t, pt.app) -} - -func TestProfilesTable_NewWithNilLogger(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - app := tview.NewApplication() - - pt := NewProfilesTable(cfg, vaultMgr, app, nil) - - assert.NotNil(t, pt) - assert.Nil(t, pt.logger) -} - func TestProfilesTable_LoadProfiles_PreservesSelection(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - app := tview.NewApplication() - logger := logrus.New() + fixtures := WithFixtures(t) - pt := NewProfilesTable(cfg, vaultMgr, app, logger) + pt := NewProfilesTable(fixtures.cfg, fixtures.interactor, fixtures.app, fixtures.logger) pt.Initialize() // First load @@ -470,17 +339,13 @@ func TestProfilesTable_LoadProfiles_PreservesSelection(t *testing.T) { } func TestProfilesTable_FormatStatus_WithError(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - app := tview.NewApplication() - logger := logrus.New() + fixtures := WithFixtures(t) - pt := NewProfilesTable(cfg, vaultMgr, app, logger) + pt := NewProfilesTable(fixtures.cfg, fixtures.interactor, fixtures.app, fixtures.logger) - status := &vault.ConnectionStatus{ - Connecting: false, - Connected: false, - Error: "connection refused", + status := &models.ConnectionStatus{ + Status: models.StatusDisconnected, + Error: "connection refused", } text, color := pt.formatStatus(status) @@ -490,12 +355,9 @@ func TestProfilesTable_FormatStatus_WithError(t *testing.T) { } func TestProfilesTable_EnsureRefresherRunning(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - app := tview.NewApplication() - logger := logrus.New() + fixtures := WithFixtures(t) - pt := NewProfilesTable(cfg, vaultMgr, app, logger) + pt := NewProfilesTable(fixtures.cfg, fixtures.interactor, fixtures.app, fixtures.logger) pt.Initialize() // Should not panic @@ -510,18 +372,15 @@ func TestProfilesTable_EnsureRefresherRunning(t *testing.T) { } func TestProfilesTable_RefreshProfiles(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - app := tview.NewApplication() - logger := logrus.New() + fixtures := WithFixtures(t) - pt := NewProfilesTable(cfg, vaultMgr, app, logger) + pt := NewProfilesTable(fixtures.cfg, fixtures.interactor, fixtures.app, fixtures.logger) pt.Initialize() // refreshProfiles() requires a fully initialized vault manager with connection manager // Testing this requires more complex setup, so we just verify the method exists // The actual functionality is tested through integration tests - assert.NotNil(t, pt.vaultMgr) + assert.NotNil(t, pt.interactor) // Clean up pt.StopRefresher() @@ -529,12 +388,9 @@ func TestProfilesTable_RefreshProfiles(t *testing.T) { } func TestProfilesTable_KeyboardNavigation_Enter(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - app := tview.NewApplication() - logger := logrus.New() + fixtures := WithFixtures(t) - pt := NewProfilesTable(cfg, vaultMgr, app, logger) + pt := NewProfilesTable(fixtures.cfg, fixtures.interactor, fixtures.app, fixtures.logger) pt.Initialize() // Simulate Enter key press (should trigger switchToSelectedVault) @@ -543,12 +399,9 @@ func TestProfilesTable_KeyboardNavigation_Enter(t *testing.T) { } func TestProfilesTable_VaultNamesInitialization(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - app := tview.NewApplication() - logger := logrus.New() + fixtures := WithFixtures(t) - pt := NewProfilesTable(cfg, vaultMgr, app, logger) + pt := NewProfilesTable(fixtures.cfg, fixtures.interactor, fixtures.app, fixtures.logger) pt.Initialize() // vaultNames should be initialized (even if empty) @@ -556,12 +409,9 @@ func TestProfilesTable_VaultNamesInitialization(t *testing.T) { } func TestProfilesTable_MultipleRefreshCycles(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - app := tview.NewApplication() - logger := logrus.New() + fixtures := WithFixtures(t) - pt := NewProfilesTable(cfg, vaultMgr, app, logger) + pt := NewProfilesTable(fixtures.cfg, fixtures.interactor, fixtures.app, fixtures.logger) pt.Initialize() // Multiple refresh cycles should not panic @@ -574,12 +424,9 @@ func TestProfilesTable_MultipleRefreshCycles(t *testing.T) { } func TestProfilesTable_CallbacksIntegration(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - app := tview.NewApplication() - logger := logrus.New() + fixtures := WithFixtures(t) - pt := NewProfilesTable(cfg, vaultMgr, app, logger) + pt := NewProfilesTable(fixtures.cfg, fixtures.interactor, fixtures.app, fixtures.logger) successCalled := false errorCalled := false diff --git a/internal/ui/panels/profiles_title.go b/internal/ui/panels/profiles_title.go index 2c35c79..73a2f0a 100644 --- a/internal/ui/panels/profiles_title.go +++ b/internal/ui/panels/profiles_title.go @@ -6,20 +6,20 @@ import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + "github.com/rvolykh/vui/internal/backend" "github.com/rvolykh/vui/internal/ui/common" - "github.com/rvolykh/vui/internal/vault" ) // ProfilesTitle represents the welcome/connection screen type ProfilesTitle struct { - vaultMgr *vault.Manager + interactor backend.Interactor hasActiveConn bool } // NewProfilesTitle creates a new welcome screen -func NewProfilesTitle(vaultMgr *vault.Manager, hasActiveConnection bool) *ProfilesTitle { +func NewProfilesTitle(interactor backend.Interactor, hasActiveConnection bool) *ProfilesTitle { return &ProfilesTitle{ - vaultMgr: vaultMgr, + interactor: interactor, hasActiveConn: hasActiveConnection, } } @@ -134,7 +134,7 @@ func (ws *ProfilesTitle) buildContent(width int) string { // getConnectionStatus returns the formatted connection status string func (ws *ProfilesTitle) getConnectionStatus() string { if ws.hasActiveConn { - activeVault := ws.vaultMgr.GetActiveVault() + activeVault := ws.interactor.Profiles().GetCurrentProfile() if activeVault != "" { return fmt.Sprintf("[green]Connected:[white] %s", activeVault) } diff --git a/internal/ui/panels/profiles_title_test.go b/internal/ui/panels/profiles_title_test.go index 0102b69..b9af1bb 100644 --- a/internal/ui/panels/profiles_title_test.go +++ b/internal/ui/panels/profiles_title_test.go @@ -4,27 +4,26 @@ import ( "strings" "testing" - "github.com/rvolykh/vui/internal/vault" "github.com/stretchr/testify/assert" ) func TestNewProfilesTitle(t *testing.T) { - vaultMgr := &vault.Manager{} + fixtures := WithFixtures(t) - pt := NewProfilesTitle(vaultMgr, false) + pt := NewProfilesTitle(fixtures.interactor, false) assert.NotNil(t, pt) - assert.Equal(t, vaultMgr, pt.vaultMgr) + assert.Equal(t, fixtures.interactor, pt.interactor) assert.False(t, pt.hasActiveConn) } func TestNewProfilesTitle_WithActiveConnection(t *testing.T) { - vaultMgr := &vault.Manager{} + fixtures := WithFixtures(t) - pt := NewProfilesTitle(vaultMgr, true) + pt := NewProfilesTitle(fixtures.interactor, true) assert.NotNil(t, pt) - assert.Equal(t, vaultMgr, pt.vaultMgr) + assert.Equal(t, fixtures.interactor, pt.interactor) assert.True(t, pt.hasActiveConn) } @@ -32,12 +31,12 @@ func TestNewProfilesTitle_WithNilVaultManager(t *testing.T) { pt := NewProfilesTitle(nil, false) assert.NotNil(t, pt) - assert.Nil(t, pt.vaultMgr) + assert.Nil(t, pt.interactor) } func TestProfilesTitle_Build(t *testing.T) { - vaultMgr := &vault.Manager{} - pt := NewProfilesTitle(vaultMgr, false) + fixtures := WithFixtures(t) + pt := NewProfilesTitle(fixtures.interactor, false) textView := pt.Build() @@ -45,8 +44,8 @@ func TestProfilesTitle_Build(t *testing.T) { } func TestProfilesTitle_Build_WithActiveConnection(t *testing.T) { - vaultMgr := &vault.Manager{} - pt := NewProfilesTitle(vaultMgr, true) + fixtures := WithFixtures(t) + pt := NewProfilesTitle(fixtures.interactor, true) textView := pt.Build() @@ -54,8 +53,8 @@ func TestProfilesTitle_Build_WithActiveConnection(t *testing.T) { } func TestProfilesTitle_BuildContent_MinimumWidth(t *testing.T) { - vaultMgr := &vault.Manager{} - pt := NewProfilesTitle(vaultMgr, false) + fixtures := WithFixtures(t) + pt := NewProfilesTitle(fixtures.interactor, false) // Test with width below minimum (should be clamped to 60) content := pt.buildContent(40) @@ -68,8 +67,8 @@ func TestProfilesTitle_BuildContent_MinimumWidth(t *testing.T) { } func TestProfilesTitle_BuildContent_MaximumWidth(t *testing.T) { - vaultMgr := &vault.Manager{} - pt := NewProfilesTitle(vaultMgr, false) + fixtures := WithFixtures(t) + pt := NewProfilesTitle(fixtures.interactor, false) // Test with width above maximum (should be clamped to 120) content := pt.buildContent(200) @@ -79,8 +78,8 @@ func TestProfilesTitle_BuildContent_MaximumWidth(t *testing.T) { } func TestProfilesTitle_BuildContent_NormalWidth(t *testing.T) { - vaultMgr := &vault.Manager{} - pt := NewProfilesTitle(vaultMgr, false) + fixtures := WithFixtures(t) + pt := NewProfilesTitle(fixtures.interactor, false) content := pt.buildContent(80) @@ -93,8 +92,8 @@ func TestProfilesTitle_BuildContent_NormalWidth(t *testing.T) { } func TestProfilesTitle_BuildContent_ContainsConfigPaths(t *testing.T) { - vaultMgr := &vault.Manager{} - pt := NewProfilesTitle(vaultMgr, false) + fixtures := WithFixtures(t) + pt := NewProfilesTitle(fixtures.interactor, false) content := pt.buildContent(80) @@ -105,8 +104,8 @@ func TestProfilesTitle_BuildContent_ContainsConfigPaths(t *testing.T) { } func TestProfilesTitle_BuildContent_ContainsNavigationItems(t *testing.T) { - vaultMgr := &vault.Manager{} - pt := NewProfilesTitle(vaultMgr, false) + fixtures := WithFixtures(t) + pt := NewProfilesTitle(fixtures.interactor, false) content := pt.buildContent(80) @@ -122,8 +121,8 @@ func TestProfilesTitle_BuildContent_ContainsNavigationItems(t *testing.T) { } func TestProfilesTitle_BuildContent_HasBorders(t *testing.T) { - vaultMgr := &vault.Manager{} - pt := NewProfilesTitle(vaultMgr, false) + fixtures := WithFixtures(t) + pt := NewProfilesTitle(fixtures.interactor, false) content := pt.buildContent(80) @@ -137,8 +136,8 @@ func TestProfilesTitle_BuildContent_HasBorders(t *testing.T) { } func TestProfilesTitle_GetConnectionStatus_NoConnection(t *testing.T) { - vaultMgr := &vault.Manager{} - pt := NewProfilesTitle(vaultMgr, false) + fixtures := WithFixtures(t) + pt := NewProfilesTitle(fixtures.interactor, false) status := pt.getConnectionStatus() @@ -147,8 +146,8 @@ func TestProfilesTitle_GetConnectionStatus_NoConnection(t *testing.T) { } func TestProfilesTitle_GetConnectionStatus_HasActiveConnection_NoVaultName(t *testing.T) { - vaultMgr := &vault.Manager{} - pt := NewProfilesTitle(vaultMgr, true) + fixtures := WithFixtures(t) + pt := NewProfilesTitle(fixtures.interactor, true) status := pt.getConnectionStatus() @@ -158,8 +157,8 @@ func TestProfilesTitle_GetConnectionStatus_HasActiveConnection_NoVaultName(t *te } func TestProfilesTitle_GetConnectionStatus_Connected(t *testing.T) { - vaultMgr := &vault.Manager{} - pt := NewProfilesTitle(vaultMgr, true) + fixtures := WithFixtures(t) + pt := NewProfilesTitle(fixtures.interactor, true) // Note: Without proper initialization, GetActiveVault will return empty string // This test verifies the logic branch @@ -169,8 +168,8 @@ func TestProfilesTitle_GetConnectionStatus_Connected(t *testing.T) { } func TestProfilesTitle_GetNavigationItems_WithoutActiveConnection(t *testing.T) { - vaultMgr := &vault.Manager{} - pt := NewProfilesTitle(vaultMgr, false) + fixtures := WithFixtures(t) + pt := NewProfilesTitle(fixtures.interactor, false) items := pt.getNavigationItems() @@ -188,8 +187,8 @@ func TestProfilesTitle_GetNavigationItems_WithoutActiveConnection(t *testing.T) } func TestProfilesTitle_GetNavigationItems_WithActiveConnection(t *testing.T) { - vaultMgr := &vault.Manager{} - pt := NewProfilesTitle(vaultMgr, true) + fixtures := WithFixtures(t) + pt := NewProfilesTitle(fixtures.interactor, true) items := pt.getNavigationItems() @@ -202,8 +201,8 @@ func TestProfilesTitle_GetNavigationItems_WithActiveConnection(t *testing.T) { } func TestProfilesTitle_NavigationItem_Structure(t *testing.T) { - vaultMgr := &vault.Manager{} - pt := NewProfilesTitle(vaultMgr, false) + fixtures := WithFixtures(t) + pt := NewProfilesTitle(fixtures.interactor, false) items := pt.getNavigationItems() @@ -215,8 +214,8 @@ func TestProfilesTitle_NavigationItem_Structure(t *testing.T) { } func TestProfilesTitle_BuildContent_DifferentWidths(t *testing.T) { - vaultMgr := &vault.Manager{} - pt := NewProfilesTitle(vaultMgr, false) + fixtures := WithFixtures(t) + pt := NewProfilesTitle(fixtures.interactor, false) widths := []int{60, 70, 80, 90, 100, 120} @@ -228,8 +227,8 @@ func TestProfilesTitle_BuildContent_DifferentWidths(t *testing.T) { } func TestProfilesTitle_BuildContent_LineCount(t *testing.T) { - vaultMgr := &vault.Manager{} - pt := NewProfilesTitle(vaultMgr, false) + fixtures := WithFixtures(t) + pt := NewProfilesTitle(fixtures.interactor, false) content := pt.buildContent(80) lines := strings.Split(content, "\n") @@ -239,8 +238,8 @@ func TestProfilesTitle_BuildContent_LineCount(t *testing.T) { } func TestProfilesTitle_BuildContent_ColorTags(t *testing.T) { - vaultMgr := &vault.Manager{} - pt := NewProfilesTitle(vaultMgr, false) + fixtures := WithFixtures(t) + pt := NewProfilesTitle(fixtures.interactor, false) content := pt.buildContent(80) @@ -252,8 +251,8 @@ func TestProfilesTitle_BuildContent_ColorTags(t *testing.T) { } func TestProfilesTitle_BuildContent_WithActiveConnection_HasEscKey(t *testing.T) { - vaultMgr := &vault.Manager{} - pt := NewProfilesTitle(vaultMgr, true) + fixtures := WithFixtures(t) + pt := NewProfilesTitle(fixtures.interactor, true) content := pt.buildContent(80) @@ -263,8 +262,8 @@ func TestProfilesTitle_BuildContent_WithActiveConnection_HasEscKey(t *testing.T) } func TestProfilesTitle_BuildContent_WithoutActiveConnection_NoEscKey(t *testing.T) { - vaultMgr := &vault.Manager{} - pt := NewProfilesTitle(vaultMgr, false) + fixtures := WithFixtures(t) + pt := NewProfilesTitle(fixtures.interactor, false) content := pt.buildContent(80) @@ -273,8 +272,8 @@ func TestProfilesTitle_BuildContent_WithoutActiveConnection_NoEscKey(t *testing. } func TestProfilesTitle_BuildContent_TwoColumnLayout(t *testing.T) { - vaultMgr := &vault.Manager{} - pt := NewProfilesTitle(vaultMgr, false) + fixtures := WithFixtures(t) + pt := NewProfilesTitle(fixtures.interactor, false) content := pt.buildContent(80) @@ -294,8 +293,8 @@ func TestProfilesTitle_BuildContent_TwoColumnLayout(t *testing.T) { } func TestProfilesTitle_BuildContent_ConsistentStructure(t *testing.T) { - vaultMgr := &vault.Manager{} - pt := NewProfilesTitle(vaultMgr, false) + fixtures := WithFixtures(t) + pt := NewProfilesTitle(fixtures.interactor, false) // Build content multiple times to ensure consistency content1 := pt.buildContent(80) @@ -305,8 +304,8 @@ func TestProfilesTitle_BuildContent_ConsistentStructure(t *testing.T) { } func TestProfilesTitle_Build_SetsDynamicColors(t *testing.T) { - vaultMgr := &vault.Manager{} - pt := NewProfilesTitle(vaultMgr, false) + fixtures := WithFixtures(t) + pt := NewProfilesTitle(fixtures.interactor, false) textView := pt.Build() @@ -315,8 +314,8 @@ func TestProfilesTitle_Build_SetsDynamicColors(t *testing.T) { } func TestProfilesTitle_BuildContent_AllNavigationItemsVisible(t *testing.T) { - vaultMgr := &vault.Manager{} - pt := NewProfilesTitle(vaultMgr, false) + fixtures := WithFixtures(t) + pt := NewProfilesTitle(fixtures.interactor, false) items := pt.getNavigationItems() content := pt.buildContent(80) @@ -329,8 +328,8 @@ func TestProfilesTitle_BuildContent_AllNavigationItemsVisible(t *testing.T) { } func TestProfilesTitle_BuildContent_WithVerySmallWidth(t *testing.T) { - vaultMgr := &vault.Manager{} - pt := NewProfilesTitle(vaultMgr, false) + fixtures := WithFixtures(t) + pt := NewProfilesTitle(fixtures.interactor, false) // Test with very small width (should be clamped to minimum) content := pt.buildContent(10) @@ -341,8 +340,8 @@ func TestProfilesTitle_BuildContent_WithVerySmallWidth(t *testing.T) { } func TestProfilesTitle_BuildContent_WithZeroWidth(t *testing.T) { - vaultMgr := &vault.Manager{} - pt := NewProfilesTitle(vaultMgr, false) + fixtures := WithFixtures(t) + pt := NewProfilesTitle(fixtures.interactor, false) // Test with zero width (should be clamped to minimum) content := pt.buildContent(0) @@ -352,8 +351,8 @@ func TestProfilesTitle_BuildContent_WithZeroWidth(t *testing.T) { } func TestProfilesTitle_BuildContent_WithNegativeWidth(t *testing.T) { - vaultMgr := &vault.Manager{} - pt := NewProfilesTitle(vaultMgr, false) + fixtures := WithFixtures(t) + pt := NewProfilesTitle(fixtures.interactor, false) // Test with negative width (should be clamped to minimum) content := pt.buildContent(-10) @@ -363,17 +362,17 @@ func TestProfilesTitle_BuildContent_WithNegativeWidth(t *testing.T) { } func TestProfilesTitle_GetNavigationItems_Order(t *testing.T) { - vaultMgr := &vault.Manager{} + fixtures := WithFixtures(t) // Test without active connection - pt1 := NewProfilesTitle(vaultMgr, false) + pt1 := NewProfilesTitle(fixtures.interactor, false) items1 := pt1.getNavigationItems() // First item should be navigation (not Esc) assert.Equal(t, "↑/↓", items1[0].keys) // Test with active connection - pt2 := NewProfilesTitle(vaultMgr, true) + pt2 := NewProfilesTitle(fixtures.interactor, true) items2 := pt2.getNavigationItems() // First item should be Esc @@ -383,8 +382,8 @@ func TestProfilesTitle_GetNavigationItems_Order(t *testing.T) { } func TestProfilesTitle_BuildContent_BottomText(t *testing.T) { - vaultMgr := &vault.Manager{} - pt := NewProfilesTitle(vaultMgr, false) + fixtures := WithFixtures(t) + pt := NewProfilesTitle(fixtures.interactor, false) content := pt.buildContent(80) @@ -393,8 +392,8 @@ func TestProfilesTitle_BuildContent_BottomText(t *testing.T) { } func TestProfilesTitle_BuildContent_NoEmptyLines(t *testing.T) { - vaultMgr := &vault.Manager{} - pt := NewProfilesTitle(vaultMgr, false) + fixtures := WithFixtures(t) + pt := NewProfilesTitle(fixtures.interactor, false) content := pt.buildContent(80) lines := strings.Split(content, "\n") @@ -411,8 +410,8 @@ func TestProfilesTitle_BuildContent_NoEmptyLines(t *testing.T) { } func TestProfilesTitle_StateIndependence(t *testing.T) { - vaultMgr := &vault.Manager{} - pt := NewProfilesTitle(vaultMgr, false) + fixtures := WithFixtures(t) + pt := NewProfilesTitle(fixtures.interactor, false) // Build content multiple times content1 := pt.buildContent(80) @@ -427,8 +426,8 @@ func TestProfilesTitle_StateIndependence(t *testing.T) { } func TestProfilesTitle_BuildContent_HelpKeyPresent(t *testing.T) { - vaultMgr := &vault.Manager{} - pt := NewProfilesTitle(vaultMgr, false) + fixtures := WithFixtures(t) + pt := NewProfilesTitle(fixtures.interactor, false) content := pt.buildContent(80) diff --git a/internal/ui/panels/secrets_metadata.go b/internal/ui/panels/secrets_metadata.go index 27789a7..2e27c52 100644 --- a/internal/ui/panels/secrets_metadata.go +++ b/internal/ui/panels/secrets_metadata.go @@ -4,26 +4,27 @@ import ( "fmt" "github.com/rivo/tview" + "github.com/rvolykh/vui/internal/backend" "github.com/rvolykh/vui/internal/config" - "github.com/rvolykh/vui/internal/vault" + "github.com/rvolykh/vui/internal/models" "github.com/sirupsen/logrus" ) // SecretsMetadata represents the secret metadata panel type SecretsMetadata struct { config *config.Config - vaultMgr *vault.Manager + interactor backend.Interactor textView *tview.TextView - currentSecret *vault.SecretNode + currentSecret *models.SecretNode logger *logrus.Logger } // NewSecretsMetadata creates a new metadata panel -func NewSecretsMetadata(config *config.Config, vaultMgr *vault.Manager, logger *logrus.Logger) *SecretsMetadata { +func NewSecretsMetadata(config *config.Config, interactor backend.Interactor, logger *logrus.Logger) *SecretsMetadata { return &SecretsMetadata{ - config: config, - vaultMgr: vaultMgr, - logger: logger, + config: config, + interactor: interactor, + logger: logger, } } @@ -47,7 +48,7 @@ func (mp *SecretsMetadata) Initialize() error { } // ShowSecret displays secret metadata -func (mp *SecretsMetadata) ShowSecret(secret *vault.SecretNode) { +func (mp *SecretsMetadata) ShowSecret(secret *models.SecretNode) { mp.currentSecret = secret mp.displayMetadata(secret) } @@ -64,7 +65,7 @@ func (mp *SecretsMetadata) ShowDirectory(path string, itemCount int) { } // displayMetadata displays secret metadata -func (mp *SecretsMetadata) displayMetadata(secret *vault.SecretNode) { +func (mp *SecretsMetadata) displayMetadata(secret *models.SecretNode) { content := fmt.Sprintf(`[yellow]Type:[white] Secret [yellow]Name:[white] %s [yellow]Path:[white] %s`, secret.Name, secret.Path) @@ -94,7 +95,7 @@ func (mp *SecretsMetadata) displayMetadata(secret *vault.SecretNode) { } // ShowKey displays metadata for a specific key within a secret -func (mp *SecretsMetadata) ShowKey(secret *vault.SecretNode, key string) { +func (mp *SecretsMetadata) ShowKey(secret *models.SecretNode, key string) { mp.currentSecret = secret content := fmt.Sprintf(`[yellow]Type:[white] Secret Key [yellow]Secret:[white] %s diff --git a/internal/ui/panels/secrets_metadata_test.go b/internal/ui/panels/secrets_metadata_test.go index f8276b9..b4f7f96 100644 --- a/internal/ui/panels/secrets_metadata_test.go +++ b/internal/ui/panels/secrets_metadata_test.go @@ -4,28 +4,22 @@ import ( "testing" "time" - "github.com/rvolykh/vui/internal/config" - "github.com/rvolykh/vui/internal/vault" - "github.com/sirupsen/logrus" + "github.com/rvolykh/vui/internal/models" "github.com/stretchr/testify/assert" ) func TestNewSecretsMetadata(t *testing.T) { - cfg := &config.Config{} - logger := logrus.New() - vaultMgr, _ := vault.NewManager(cfg, logger) + fixtures := WithFixtures(t) - panel := NewSecretsMetadata(cfg, vaultMgr, logger) + panel := NewSecretsMetadata(fixtures.cfg, fixtures.interactor, fixtures.logger) assert.NotNil(t, panel) } func TestSecretsMetadata_Initialize(t *testing.T) { - cfg := &config.Config{} - logger := logrus.New() - vaultMgr, _ := vault.NewManager(cfg, logger) + fixtures := WithFixtures(t) - panel := NewSecretsMetadata(cfg, vaultMgr, logger) + panel := NewSecretsMetadata(fixtures.cfg, fixtures.interactor, fixtures.logger) err := panel.Initialize() assert.NoError(t, err) @@ -33,14 +27,11 @@ func TestSecretsMetadata_Initialize(t *testing.T) { } func TestSecretsMetadata_ShowSecret(t *testing.T) { - cfg := &config.Config{} - logger := logrus.New() - vaultMgr, _ := vault.NewManager(cfg, logger) - - panel := NewSecretsMetadata(cfg, vaultMgr, logger) + fixtures := WithFixtures(t) + panel := NewSecretsMetadata(fixtures.cfg, fixtures.interactor, fixtures.logger) panel.Initialize() - secret := &vault.SecretNode{ + secret := &models.SecretNode{ Name: "test-secret", Path: "secrets/test", IsSecret: true, @@ -48,7 +39,7 @@ func TestSecretsMetadata_ShowSecret(t *testing.T) { "password": "secret123", "username": "admin", }, - Metadata: &vault.SecretMetadata{ + Metadata: &models.SecretMetadata{ Version: 2, CreatedTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC), Destroyed: false, @@ -61,11 +52,8 @@ func TestSecretsMetadata_ShowSecret(t *testing.T) { } func TestSecretsMetadata_ShowDirectory(t *testing.T) { - cfg := &config.Config{} - logger := logrus.New() - vaultMgr, _ := vault.NewManager(cfg, logger) - - panel := NewSecretsMetadata(cfg, vaultMgr, logger) + fixtures := WithFixtures(t) + panel := NewSecretsMetadata(fixtures.cfg, fixtures.interactor, fixtures.logger) panel.Initialize() panel.ShowDirectory("secrets/test", 5) @@ -74,14 +62,10 @@ func TestSecretsMetadata_ShowDirectory(t *testing.T) { } func TestSecretsMetadata_ShowKey(t *testing.T) { - cfg := &config.Config{} - logger := logrus.New() - vaultMgr, _ := vault.NewManager(cfg, logger) - - panel := NewSecretsMetadata(cfg, vaultMgr, logger) + fixtures := WithFixtures(t) + panel := NewSecretsMetadata(fixtures.cfg, fixtures.interactor, fixtures.logger) panel.Initialize() - - secret := &vault.SecretNode{ + secret := &models.SecretNode{ Name: "test-secret", Path: "secrets/test", IsSecret: true, @@ -96,21 +80,17 @@ func TestSecretsMetadata_ShowKey(t *testing.T) { } func TestSecretsMetadata_Refresh(t *testing.T) { - cfg := &config.Config{} - logger := logrus.New() - vaultMgr, _ := vault.NewManager(cfg, logger) - - panel := NewSecretsMetadata(cfg, vaultMgr, logger) + fixtures := WithFixtures(t) + panel := NewSecretsMetadata(fixtures.cfg, fixtures.interactor, fixtures.logger) panel.Initialize() - - secret := &vault.SecretNode{ + secret := &models.SecretNode{ Name: "test-secret", Path: "secrets/test", IsSecret: true, Data: map[string]interface{}{ "password": "secret123", }, - Metadata: &vault.SecretMetadata{ + Metadata: &models.SecretMetadata{ Version: 1, CreatedTime: time.Now(), }, diff --git a/internal/ui/panels/secrets_status.go b/internal/ui/panels/secrets_status.go index 98fb013..b97dad3 100644 --- a/internal/ui/panels/secrets_status.go +++ b/internal/ui/panels/secrets_status.go @@ -5,25 +5,26 @@ import ( "time" "github.com/rivo/tview" + "github.com/rvolykh/vui/internal/backend" "github.com/rvolykh/vui/internal/config" - "github.com/rvolykh/vui/internal/vault" + "github.com/rvolykh/vui/internal/models" "github.com/sirupsen/logrus" ) // SecretsStatus represents the status bar type SecretsStatus struct { - config *config.Config - vaultMgr *vault.Manager - textView *tview.TextView - logger *logrus.Logger + config *config.Config + interactor backend.Interactor + textView *tview.TextView + logger *logrus.Logger } // NewSecretsStatus creates a new status bar -func NewSecretsStatus(config *config.Config, vaultMgr *vault.Manager, logger *logrus.Logger) *SecretsStatus { +func NewSecretsStatus(config *config.Config, interactor backend.Interactor, logger *logrus.Logger) *SecretsStatus { return &SecretsStatus{ - config: config, - vaultMgr: vaultMgr, - logger: logger, + config: config, + interactor: interactor, + logger: logger, } } @@ -46,10 +47,10 @@ func (sb *SecretsStatus) Initialize() error { // UpdateConnectionStatus updates the connection status func (sb *SecretsStatus) UpdateConnectionStatus() { // Get active vault - activeVault := sb.vaultMgr.GetActiveVault() + activeVault := sb.interactor.Profiles().GetCurrentProfile() // Get connection status - status, err := sb.vaultMgr.GetConnectionStatus(activeVault) + status, err := sb.interactor.Profiles().GetConnectionStatus(activeVault) if err != nil { sb.updateStatus(fmt.Sprintf("[red]Error: %v[white]", err)) return @@ -57,8 +58,8 @@ func (sb *SecretsStatus) UpdateConnectionStatus() { // Format status var statusText string - if status.Connected { - if status.Sealed { + if status.Status == models.StatusConnected { + if status.Status == models.StatusSealed { statusText = fmt.Sprintf("[yellow]Vault: %s (Sealed)[white]", activeVault) } else { statusText = fmt.Sprintf("[green]Vault: %s (Connected)[white]", activeVault) diff --git a/internal/ui/panels/secrets_status_test.go b/internal/ui/panels/secrets_status_test.go index ab5bff2..79cebab 100644 --- a/internal/ui/panels/secrets_status_test.go +++ b/internal/ui/panels/secrets_status_test.go @@ -3,28 +3,21 @@ package panels import ( "testing" - "github.com/rvolykh/vui/internal/config" - "github.com/rvolykh/vui/internal/vault" - "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" ) func TestNewSecretsStatus(t *testing.T) { - cfg := &config.Config{} - logger := logrus.New() - vaultMgr, _ := vault.NewManager(cfg, logger) + fixtures := WithFixtures(t) - statusBar := NewSecretsStatus(cfg, vaultMgr, logger) + statusBar := NewSecretsStatus(fixtures.cfg, fixtures.interactor, fixtures.logger) assert.NotNil(t, statusBar) } func TestSecretsStatus_Initialize(t *testing.T) { - cfg := &config.Config{} - logger := logrus.New() - vaultMgr, _ := vault.NewManager(cfg, logger) + fixtures := WithFixtures(t) - statusBar := NewSecretsStatus(cfg, vaultMgr, logger) + statusBar := NewSecretsStatus(fixtures.cfg, fixtures.interactor, fixtures.logger) err := statusBar.Initialize() assert.NoError(t, err) @@ -32,11 +25,9 @@ func TestSecretsStatus_Initialize(t *testing.T) { } func TestSecretsStatus_UpdateSelection(t *testing.T) { - cfg := &config.Config{} - logger := logrus.New() - vaultMgr, _ := vault.NewManager(cfg, logger) + fixtures := WithFixtures(t) - statusBar := NewSecretsStatus(cfg, vaultMgr, logger) + statusBar := NewSecretsStatus(fixtures.cfg, fixtures.interactor, fixtures.logger) statusBar.Initialize() tests := []struct { diff --git a/internal/ui/panels/secrets_title.go b/internal/ui/panels/secrets_title.go index 74ffe5e..caccb01 100644 --- a/internal/ui/panels/secrets_title.go +++ b/internal/ui/panels/secrets_title.go @@ -4,25 +4,25 @@ import ( "fmt" "github.com/rivo/tview" + "github.com/rvolykh/vui/internal/backend" "github.com/rvolykh/vui/internal/config" - "github.com/rvolykh/vui/internal/vault" "github.com/sirupsen/logrus" ) // SecretsTitle represents the navigation secrets title type SecretsTitle struct { - config *config.Config - vaultMgr *vault.Manager - textView *tview.TextView - logger *logrus.Logger + config *config.Config + interactor backend.Interactor + textView *tview.TextView + logger *logrus.Logger } // NewSecretsTitle creates a new secrets title -func NewSecretsTitle(config *config.Config, vaultMgr *vault.Manager, logger *logrus.Logger) *SecretsTitle { +func NewSecretsTitle(config *config.Config, interactor backend.Interactor, logger *logrus.Logger) *SecretsTitle { return &SecretsTitle{ - config: config, - vaultMgr: vaultMgr, - logger: logger, + config: config, + interactor: interactor, + logger: logger, } } @@ -63,16 +63,16 @@ func (hp *SecretsTitle) updateHelpText() { // getVaultInfo returns the current vault connection information func (hp *SecretsTitle) getVaultInfo() string { - if hp.vaultMgr == nil { + if hp.interactor == nil { return "[red]No connection[white]" } - currentVault := hp.vaultMgr.GetActiveVault() + currentVault := hp.interactor.Profiles().GetCurrentProfile() if currentVault == "" { - return "[red]No vault selected[white]" + return "[red]No profile selected[white]" } - // Get vault profile for address + // Get profile for address if profile, ok := hp.config.Vaults[currentVault]; ok { return fmt.Sprintf("[green]%s[white] (%s)", currentVault, profile.Address) } diff --git a/internal/ui/panels/secrets_title_test.go b/internal/ui/panels/secrets_title_test.go index c64f0c2..f490098 100644 --- a/internal/ui/panels/secrets_title_test.go +++ b/internal/ui/panels/secrets_title_test.go @@ -5,70 +5,25 @@ import ( "testing" "github.com/rvolykh/vui/internal/config" - "github.com/rvolykh/vui/internal/vault" - "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" ) func TestNewSecretsTitle(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() + fixtures := WithFixtures(t) - st := NewSecretsTitle(cfg, vaultMgr, logger) + st := NewSecretsTitle(fixtures.cfg, fixtures.interactor, fixtures.logger) assert.NotNil(t, st) - assert.Equal(t, cfg, st.config) - assert.Equal(t, vaultMgr, st.vaultMgr) - assert.Equal(t, logger, st.logger) + assert.Equal(t, fixtures.cfg, st.config) + assert.Equal(t, fixtures.interactor, st.interactor) + assert.Equal(t, fixtures.logger, st.logger) assert.Nil(t, st.textView) // Not initialized yet } -func TestNewSecretsTitle_WithNilConfig(t *testing.T) { - vaultMgr := &vault.Manager{} - logger := logrus.New() - - st := NewSecretsTitle(nil, vaultMgr, logger) - - assert.NotNil(t, st) - assert.Nil(t, st.config) -} - -func TestNewSecretsTitle_WithNilVaultManager(t *testing.T) { - cfg := &config.Config{} - logger := logrus.New() - - st := NewSecretsTitle(cfg, nil, logger) - - assert.NotNil(t, st) - assert.Nil(t, st.vaultMgr) -} - -func TestNewSecretsTitle_WithNilLogger(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - - st := NewSecretsTitle(cfg, vaultMgr, nil) - - assert.NotNil(t, st) - assert.Nil(t, st.logger) -} - -func TestNewSecretsTitle_AllNil(t *testing.T) { - st := NewSecretsTitle(nil, nil, nil) - - assert.NotNil(t, st) - assert.Nil(t, st.config) - assert.Nil(t, st.vaultMgr) - assert.Nil(t, st.logger) -} - func TestSecretsTitle_Initialize(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() + fixtures := WithFixtures(t) + st := NewSecretsTitle(fixtures.cfg, fixtures.interactor, fixtures.logger) - st := NewSecretsTitle(cfg, vaultMgr, logger) err := st.Initialize() assert.NoError(t, err) @@ -76,11 +31,9 @@ func TestSecretsTitle_Initialize(t *testing.T) { } func TestSecretsTitle_Initialize_CreatesTextView(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() + fixtures := WithFixtures(t) + st := NewSecretsTitle(fixtures.cfg, fixtures.interactor, fixtures.logger) - st := NewSecretsTitle(cfg, vaultMgr, logger) err := st.Initialize() assert.NoError(t, err) @@ -92,11 +45,8 @@ func TestSecretsTitle_Initialize_CreatesTextView(t *testing.T) { } func TestSecretsTitle_Initialize_MultipleCallsSafe(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - - st := NewSecretsTitle(cfg, vaultMgr, logger) + fixtures := WithFixtures(t) + st := NewSecretsTitle(fixtures.cfg, fixtures.interactor, fixtures.logger) // Initialize multiple times should not panic err1 := st.Initialize() @@ -110,11 +60,8 @@ func TestSecretsTitle_Initialize_MultipleCallsSafe(t *testing.T) { } func TestSecretsTitle_GetPrimitive(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - - st := NewSecretsTitle(cfg, vaultMgr, logger) + fixtures := WithFixtures(t) + st := NewSecretsTitle(fixtures.cfg, fixtures.interactor, fixtures.logger) st.Initialize() primitive := st.GetPrimitive() @@ -124,11 +71,8 @@ func TestSecretsTitle_GetPrimitive(t *testing.T) { } func TestSecretsTitle_GetPrimitive_BeforeInitialize(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - - st := NewSecretsTitle(cfg, vaultMgr, logger) + fixtures := WithFixtures(t) + st := NewSecretsTitle(fixtures.cfg, fixtures.interactor, fixtures.logger) primitive := st.GetPrimitive() @@ -136,10 +80,8 @@ func TestSecretsTitle_GetPrimitive_BeforeInitialize(t *testing.T) { } func TestSecretsTitle_GetVaultInfo_NoVaultManager(t *testing.T) { - cfg := &config.Config{} - logger := logrus.New() - - st := NewSecretsTitle(cfg, nil, logger) + fixtures := WithFixtures(t) + st := NewSecretsTitle(fixtures.cfg, nil, fixtures.logger) info := st.getVaultInfo() @@ -148,27 +90,20 @@ func TestSecretsTitle_GetVaultInfo_NoVaultManager(t *testing.T) { } func TestSecretsTitle_GetVaultInfo_NoActiveVault(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - - st := NewSecretsTitle(cfg, vaultMgr, logger) + fixtures := WithFixtures(t) + st := NewSecretsTitle(fixtures.cfg, fixtures.interactor, fixtures.logger) info := st.getVaultInfo() // GetActiveVault() returns empty string when no vault is active - assert.Contains(t, info, "No vault selected") + assert.Contains(t, info, "No profile selected") assert.Contains(t, info, "[red]") } func TestSecretsTitle_GetVaultInfo_WithVaultNoProfile(t *testing.T) { - cfg := &config.Config{ - Vaults: map[string]config.VaultProfile{}, - } - vaultMgr := &vault.Manager{} - logger := logrus.New() - - st := NewSecretsTitle(cfg, vaultMgr, logger) + fixtures := WithFixtures(t) + fixtures.cfg.Vaults = map[string]config.VaultProfile{} + st := NewSecretsTitle(fixtures.cfg, fixtures.interactor, fixtures.logger) // With uninitialized vault manager, GetActiveVault returns empty string info := st.getVaultInfo() @@ -177,17 +112,13 @@ func TestSecretsTitle_GetVaultInfo_WithVaultNoProfile(t *testing.T) { } func TestSecretsTitle_GetVaultInfo_WithProfile(t *testing.T) { - cfg := &config.Config{ - Vaults: map[string]config.VaultProfile{ - "test-vault": { - Address: "https://vault.example.com", - }, + fixtures := WithFixtures(t) + fixtures.cfg.Vaults = map[string]config.VaultProfile{ + "test-vault": { + Address: "https://vault.example.com", }, } - vaultMgr := &vault.Manager{} - logger := logrus.New() - - st := NewSecretsTitle(cfg, vaultMgr, logger) + st := NewSecretsTitle(fixtures.cfg, fixtures.interactor, fixtures.logger) // With uninitialized vault manager, GetActiveVault returns empty string // so this will still return "No vault selected" @@ -197,11 +128,8 @@ func TestSecretsTitle_GetVaultInfo_WithProfile(t *testing.T) { } func TestSecretsTitle_UpdateHelpText_ContainsNavigationSection(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - - st := NewSecretsTitle(cfg, vaultMgr, logger) + fixtures := WithFixtures(t) + st := NewSecretsTitle(fixtures.cfg, fixtures.interactor, fixtures.logger) st.Initialize() text := st.textView.GetText(false) @@ -213,11 +141,8 @@ func TestSecretsTitle_UpdateHelpText_ContainsNavigationSection(t *testing.T) { } func TestSecretsTitle_UpdateHelpText_ContainsSecretsSection(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - - st := NewSecretsTitle(cfg, vaultMgr, logger) + fixtures := WithFixtures(t) + st := NewSecretsTitle(fixtures.cfg, fixtures.interactor, fixtures.logger) st.Initialize() text := st.textView.GetText(false) @@ -229,11 +154,8 @@ func TestSecretsTitle_UpdateHelpText_ContainsSecretsSection(t *testing.T) { } func TestSecretsTitle_UpdateHelpText_ContainsValuesSection(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - - st := NewSecretsTitle(cfg, vaultMgr, logger) + fixtures := WithFixtures(t) + st := NewSecretsTitle(fixtures.cfg, fixtures.interactor, fixtures.logger) st.Initialize() text := st.textView.GetText(false) @@ -244,11 +166,8 @@ func TestSecretsTitle_UpdateHelpText_ContainsValuesSection(t *testing.T) { } func TestSecretsTitle_UpdateHelpText_ContainsGlobalSection(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - - st := NewSecretsTitle(cfg, vaultMgr, logger) + fixtures := WithFixtures(t) + st := NewSecretsTitle(fixtures.cfg, fixtures.interactor, fixtures.logger) st.Initialize() text := st.textView.GetText(false) @@ -261,11 +180,8 @@ func TestSecretsTitle_UpdateHelpText_ContainsGlobalSection(t *testing.T) { } func TestSecretsTitle_UpdateHelpText_ContainsVaultInfo(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - - st := NewSecretsTitle(cfg, vaultMgr, logger) + fixtures := WithFixtures(t) + st := NewSecretsTitle(fixtures.cfg, fixtures.interactor, fixtures.logger) st.Initialize() text := st.textView.GetText(false) @@ -274,11 +190,8 @@ func TestSecretsTitle_UpdateHelpText_ContainsVaultInfo(t *testing.T) { } func TestSecretsTitle_UpdateHelpText_HasColorTags(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - - st := NewSecretsTitle(cfg, vaultMgr, logger) + fixtures := WithFixtures(t) + st := NewSecretsTitle(fixtures.cfg, fixtures.interactor, fixtures.logger) st.Initialize() text := st.textView.GetText(false) @@ -289,11 +202,8 @@ func TestSecretsTitle_UpdateHelpText_HasColorTags(t *testing.T) { } func TestSecretsTitle_UpdateVaultInfo(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - - st := NewSecretsTitle(cfg, vaultMgr, logger) + fixtures := WithFixtures(t) + st := NewSecretsTitle(fixtures.cfg, fixtures.interactor, fixtures.logger) st.Initialize() initialText := st.textView.GetText(false) @@ -308,11 +218,8 @@ func TestSecretsTitle_UpdateVaultInfo(t *testing.T) { } func TestSecretsTitle_UpdateVaultInfo_UpdatesText(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - - st := NewSecretsTitle(cfg, vaultMgr, logger) + fixtures := WithFixtures(t) + st := NewSecretsTitle(fixtures.cfg, fixtures.interactor, fixtures.logger) st.Initialize() // Should not panic @@ -323,11 +230,8 @@ func TestSecretsTitle_UpdateVaultInfo_UpdatesText(t *testing.T) { } func TestSecretsTitle_Initialize_TextViewHasBorder(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - - st := NewSecretsTitle(cfg, vaultMgr, logger) + fixtures := WithFixtures(t) + st := NewSecretsTitle(fixtures.cfg, fixtures.interactor, fixtures.logger) st.Initialize() // We can't directly check if border is set, but we can verify textView exists @@ -335,11 +239,8 @@ func TestSecretsTitle_Initialize_TextViewHasBorder(t *testing.T) { } func TestSecretsTitle_Initialize_TextViewHasTitle(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - - st := NewSecretsTitle(cfg, vaultMgr, logger) + fixtures := WithFixtures(t) + st := NewSecretsTitle(fixtures.cfg, fixtures.interactor, fixtures.logger) st.Initialize() // The title should be "Navigation & Controls" @@ -348,11 +249,8 @@ func TestSecretsTitle_Initialize_TextViewHasTitle(t *testing.T) { } func TestSecretsTitle_HelpText_FormattedInColumns(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - - st := NewSecretsTitle(cfg, vaultMgr, logger) + fixtures := WithFixtures(t) + st := NewSecretsTitle(fixtures.cfg, fixtures.interactor, fixtures.logger) st.Initialize() text := st.textView.GetText(false) @@ -376,29 +274,23 @@ func TestSecretsTitle_HelpText_FormattedInColumns(t *testing.T) { } func TestSecretsTitle_GetVaultInfo_ColorCoding(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - - st := NewSecretsTitle(cfg, vaultMgr, logger) + fixtures := WithFixtures(t) + st := NewSecretsTitle(fixtures.cfg, fixtures.interactor, fixtures.logger) // With nil vault manager - st.vaultMgr = nil + st.interactor = nil info := st.getVaultInfo() assert.Contains(t, info, "[red]", "No connection should be red") // With vault manager but no active vault - st.vaultMgr = &vault.Manager{} + st.interactor = fixtures.interactor info = st.getVaultInfo() assert.Contains(t, info, "[red]", "No vault selected should be red") } func TestSecretsTitle_AllKeybindings_Present(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - - st := NewSecretsTitle(cfg, vaultMgr, logger) + fixtures := WithFixtures(t) + st := NewSecretsTitle(fixtures.cfg, fixtures.interactor, fixtures.logger) st.Initialize() text := st.textView.GetText(false) @@ -425,11 +317,8 @@ func TestSecretsTitle_AllKeybindings_Present(t *testing.T) { } func TestSecretsTitle_TextNotEmpty_AfterInitialize(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - - st := NewSecretsTitle(cfg, vaultMgr, logger) + fixtures := WithFixtures(t) + st := NewSecretsTitle(fixtures.cfg, fixtures.interactor, fixtures.logger) st.Initialize() text := st.textView.GetText(false) @@ -439,11 +328,8 @@ func TestSecretsTitle_TextNotEmpty_AfterInitialize(t *testing.T) { } func TestSecretsTitle_ConsistentOutput(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - - st := NewSecretsTitle(cfg, vaultMgr, logger) + fixtures := WithFixtures(t) + st := NewSecretsTitle(fixtures.cfg, fixtures.interactor, fixtures.logger) st.Initialize() text1 := st.textView.GetText(false) @@ -458,10 +344,8 @@ func TestSecretsTitle_ConsistentOutput(t *testing.T) { } func TestSecretsTitle_GetVaultInfo_NilConfig(t *testing.T) { - vaultMgr := &vault.Manager{} - logger := logrus.New() - - st := NewSecretsTitle(nil, vaultMgr, logger) + fixtures := WithFixtures(t) + st := NewSecretsTitle(nil, fixtures.interactor, fixtures.logger) // Should not panic with nil config info := st.getVaultInfo() @@ -470,10 +354,8 @@ func TestSecretsTitle_GetVaultInfo_NilConfig(t *testing.T) { } func TestSecretsTitle_Initialize_WithNilVaultManager(t *testing.T) { - cfg := &config.Config{} - logger := logrus.New() - - st := NewSecretsTitle(cfg, nil, logger) + fixtures := WithFixtures(t) + st := NewSecretsTitle(fixtures.cfg, nil, fixtures.logger) err := st.Initialize() assert.NoError(t, err) @@ -484,13 +366,9 @@ func TestSecretsTitle_Initialize_WithNilVaultManager(t *testing.T) { } func TestSecretsTitle_GetVaultInfo_EmptyVaultsMap(t *testing.T) { - cfg := &config.Config{ - Vaults: map[string]config.VaultProfile{}, - } - vaultMgr := &vault.Manager{} - logger := logrus.New() - - st := NewSecretsTitle(cfg, vaultMgr, logger) + fixtures := WithFixtures(t) + fixtures.cfg.Vaults = map[string]config.VaultProfile{} + st := NewSecretsTitle(fixtures.cfg, fixtures.interactor, fixtures.logger) info := st.getVaultInfo() @@ -498,11 +376,8 @@ func TestSecretsTitle_GetVaultInfo_EmptyVaultsMap(t *testing.T) { } func TestSecretsTitle_TextContains_AllSectionHeaders(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - - st := NewSecretsTitle(cfg, vaultMgr, logger) + fixtures := WithFixtures(t) + st := NewSecretsTitle(fixtures.cfg, fixtures.interactor, fixtures.logger) st.Initialize() text := st.textView.GetText(false) @@ -515,11 +390,8 @@ func TestSecretsTitle_TextContains_AllSectionHeaders(t *testing.T) { } func TestSecretsTitle_UpdateVaultInfo_BeforeInitialize(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - - st := NewSecretsTitle(cfg, vaultMgr, logger) + fixtures := WithFixtures(t) + st := NewSecretsTitle(fixtures.cfg, fixtures.interactor, fixtures.logger) // Calling UpdateVaultInfo before Initialize will panic because textView is nil // This is expected behavior - Initialize must be called first @@ -527,17 +399,13 @@ func TestSecretsTitle_UpdateVaultInfo_BeforeInitialize(t *testing.T) { } func TestSecretsTitle_GetVaultInfo_WithAddress(t *testing.T) { - cfg := &config.Config{ - Vaults: map[string]config.VaultProfile{ - "prod": { - Address: "https://vault.prod.example.com", - }, + fixtures := WithFixtures(t) + fixtures.cfg.Vaults = map[string]config.VaultProfile{ + "prod": { + Address: "https://vault.prod.example.com", }, } - vaultMgr := &vault.Manager{} - logger := logrus.New() - - st := NewSecretsTitle(cfg, vaultMgr, logger) + st := NewSecretsTitle(fixtures.cfg, fixtures.interactor, fixtures.logger) // Since GetActiveVault returns empty string, this won't show the address // but the code path exists diff --git a/internal/ui/panels/secrets_tree.go b/internal/ui/panels/secrets_tree.go index 48ff725..4fd903b 100644 --- a/internal/ui/panels/secrets_tree.go +++ b/internal/ui/panels/secrets_tree.go @@ -7,22 +7,23 @@ import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + "github.com/rvolykh/vui/internal/backend" "github.com/rvolykh/vui/internal/config" + "github.com/rvolykh/vui/internal/models" "github.com/rvolykh/vui/internal/ui/forms" "github.com/rvolykh/vui/internal/ui/handlers" - "github.com/rvolykh/vui/internal/vault" "github.com/sirupsen/logrus" ) // SecretsTree represents the secrets tree panel type SecretsTree struct { config *config.Config - vaultMgr *vault.Manager + interactor backend.Interactor tree *tview.TreeView rootNode *tview.TreeNode formsMgr *forms.FormsManager clipboardHandler *handlers.ClipboardHandler - selectionHandler func(*vault.SecretNode, string) + selectionHandler func(*models.SecretNode, string) refreshHandler func() modalHandler func(tview.Primitive, bool) // Handler to show/hide modals valuePanel *SecretsValue // Reference to value panel for mask toggle @@ -32,12 +33,12 @@ type SecretsTree struct { } // NewSecretsTree creates a new tree panel -func NewSecretsTree(config *config.Config, vaultMgr *vault.Manager, logger *logrus.Logger, app *tview.Application) *SecretsTree { +func NewSecretsTree(config *config.Config, interactor backend.Interactor, logger *logrus.Logger, app *tview.Application) *SecretsTree { return &SecretsTree{ config: config, - vaultMgr: vaultMgr, - formsMgr: forms.NewFormsManager(config, vaultMgr, logger, app), - clipboardHandler: handlers.NewClipboardHandler(vaultMgr), + interactor: interactor, + formsMgr: forms.NewFormsManager(config, interactor, logger, app), + clipboardHandler: handlers.NewClipboardHandler(interactor), app: app, logger: logger, } @@ -121,7 +122,7 @@ func (tp *SecretsTree) setupKeyboardNavigation() { func (tp *SecretsTree) loadTree() error { tp.logger.Info("Loading secrets tree...") - secretsManager, err := tp.vaultMgr.GetSecretsManager() + secretsInteractor, err := tp.interactor.Secrets() if err != nil { tp.logger.Warnf("Failed to get secrets manager: %v", err) // Create an empty tree with a message @@ -129,7 +130,7 @@ func (tp *SecretsTree) loadTree() error { } // Build the tree structure - rootNode, err := tp.buildTree(secretsManager, "") + rootNode, err := tp.buildTree(secretsInteractor, "") if err != nil { tp.logger.Warnf("Failed to build tree: %v", err) // Create an empty tree with an error message @@ -170,11 +171,11 @@ func (tp *SecretsTree) createEmptyTree(message string) error { } // buildTree builds the tree structure recursively -func (tp *SecretsTree) buildTree(secretsManager *vault.SecretsManager, path string) (*tview.TreeNode, error) { +func (tp *SecretsTree) buildTree(secretsInteractor backend.SecretsInteractor, path string) (*tview.TreeNode, error) { tp.logger.Infof("Building tree for path: '%s'", path) // Get secrets at this path - secrets, err := secretsManager.ListSecrets(path) + secrets, err := secretsInteractor.ListSecrets(path) if err != nil { tp.logger.Errorf("Failed to list secrets at path '%s': %v", path, err) return nil, fmt.Errorf("failed to list secrets at path '%s': %w", path, err) @@ -237,7 +238,7 @@ func (tp *SecretsTree) handleNodeChanged(node *tview.TreeNode) { parent := tp.findParentNode(node) if parent != nil { if parentRef := parent.GetReference(); parentRef != nil { - if secret, ok := parentRef.(*vault.SecretNode); ok { + if secret, ok := parentRef.(*models.SecretNode); ok { tp.logger.Infof("Key navigated to: %s in secret %s", keyRef, secret.Path) if tp.selectionHandler != nil { tp.selectionHandler(secret, keyRef) @@ -248,7 +249,7 @@ func (tp *SecretsTree) handleNodeChanged(node *tview.TreeNode) { } } - secret, ok := reference.(*vault.SecretNode) + secret, ok := reference.(*models.SecretNode) if !ok { return } @@ -276,7 +277,7 @@ func (tp *SecretsTree) handleNodeSelection(node *tview.TreeNode) { return } - secret, ok := reference.(*vault.SecretNode) + secret, ok := reference.(*models.SecretNode) if !ok { tp.logger.Debug("Node selection: reference is not a SecretNode") return @@ -309,7 +310,7 @@ func (tp *SecretsTree) expandDirectory(node *tview.TreeNode, path string) { // First time expanding - load the children tp.logger.Infof("Loading children for directory: %s", path) - secretsManager, err := tp.vaultMgr.GetSecretsManager() + secretsInteractor, err := tp.interactor.Secrets() if err != nil { tp.logger.Errorf("Failed to get secrets manager: %v", err) // Add error node @@ -321,7 +322,7 @@ func (tp *SecretsTree) expandDirectory(node *tview.TreeNode, path string) { } // Get secrets in this directory - secrets, err := secretsManager.ListSecrets(path) + secrets, err := secretsInteractor.ListSecrets(path) if err != nil { tp.logger.Errorf("Failed to list secrets in directory '%s': %v", path, err) // Add error node @@ -368,7 +369,7 @@ func (tp *SecretsTree) expandDirectory(node *tview.TreeNode, path string) { } // expandSecret expands a secret node to show its keys -func (tp *SecretsTree) expandSecret(node *tview.TreeNode, secret *vault.SecretNode) { +func (tp *SecretsTree) expandSecret(node *tview.TreeNode, secret *models.SecretNode) { tp.logger.Infof("Expanding secret: %s", secret.Path) // Check if already loaded (has children) @@ -383,7 +384,7 @@ func (tp *SecretsTree) expandSecret(node *tview.TreeNode, secret *vault.SecretNo // First time expanding - load the secret data to get keys tp.logger.Infof("Loading keys for secret: %s", secret.Path) - secretsManager, err := tp.vaultMgr.GetSecretsManager() + secretsInteractor, err := tp.interactor.Secrets() if err != nil { tp.logger.Errorf("Failed to get secrets manager: %v", err) // Add error node @@ -395,7 +396,7 @@ func (tp *SecretsTree) expandSecret(node *tview.TreeNode, secret *vault.SecretNo } // Get the full secret with data - fullSecret, err := secretsManager.GetSecret(secret.Path) + fullSecret, err := secretsInteractor.GetSecret(secret.Path) if err != nil { tp.logger.Errorf("Failed to get secret '%s': %v", secret.Path, err) // Add error node @@ -475,7 +476,7 @@ func (tp *SecretsTree) createSecret() { var basePath string reference := node.GetReference() if reference != nil { - if secret, ok := reference.(*vault.SecretNode); ok { + if secret, ok := reference.(*models.SecretNode); ok { if secret.IsSecret { // If a secret is selected, use its parent directory basePath = strings.TrimSuffix(secret.Path, "/"+secret.Name) @@ -510,7 +511,7 @@ func (tp *SecretsTree) editSecret() { return } - secret, ok := reference.(*vault.SecretNode) + secret, ok := reference.(*models.SecretNode) if !ok || !secret.IsSecret { return } @@ -539,7 +540,7 @@ func (tp *SecretsTree) deleteSecret() { return } - secret, ok := reference.(*vault.SecretNode) + secret, ok := reference.(*models.SecretNode) if !ok || !secret.IsSecret { return } @@ -573,7 +574,7 @@ func (tp *SecretsTree) Refresh() { } // SetSelectionHandler sets the selection handler -func (tp *SecretsTree) SetSelectionHandler(handler func(*vault.SecretNode, string)) { +func (tp *SecretsTree) SetSelectionHandler(handler func(*models.SecretNode, string)) { tp.selectionHandler = handler } @@ -633,7 +634,7 @@ func (tp *SecretsTree) copySecretValue() { parent := tp.findParentNode(node) if parent != nil { if parentRef := parent.GetReference(); parentRef != nil { - if secret, ok := parentRef.(*vault.SecretNode); ok { + if secret, ok := parentRef.(*models.SecretNode); ok { if tp.copyKeyValue(secret, keyRef) { copiedMessage = fmt.Sprintf("Copied '%s' key '%s' value to clipboard", secret.Path, keyRef) } @@ -644,7 +645,7 @@ func (tp *SecretsTree) copySecretValue() { } // Otherwise, check if it's a secret node - if secret, ok := reference.(*vault.SecretNode); ok && secret.IsSecret { + if secret, ok := reference.(*models.SecretNode); ok && secret.IsSecret { if tp.copySecretValues(secret) { copiedMessage = fmt.Sprintf("Copied '%s' value(s) to clipboard", secret.Path) } @@ -652,7 +653,7 @@ func (tp *SecretsTree) copySecretValue() { } // copyKeyValue copies a specific key's value from a secret -func (tp *SecretsTree) copyKeyValue(secret *vault.SecretNode, key string) bool { +func (tp *SecretsTree) copyKeyValue(secret *models.SecretNode, key string) bool { if err := tp.clipboardHandler.CopyKeyValue(secret, key); err != nil { tp.logger.Errorf("Failed to copy key value: %v", err) return false @@ -662,7 +663,7 @@ func (tp *SecretsTree) copyKeyValue(secret *vault.SecretNode, key string) bool { } // copySecretValues copies all values from a secret (or single value if only one key) -func (tp *SecretsTree) copySecretValues(secret *vault.SecretNode) bool { +func (tp *SecretsTree) copySecretValues(secret *models.SecretNode) bool { if err := tp.clipboardHandler.CopySecretValues(secret); err != nil { tp.logger.Errorf("Failed to copy secret values: %v", err) return false diff --git a/internal/ui/panels/secrets_tree_test.go b/internal/ui/panels/secrets_tree_test.go index 106a996..68fe5bc 100644 --- a/internal/ui/panels/secrets_tree_test.go +++ b/internal/ui/panels/secrets_tree_test.go @@ -4,25 +4,20 @@ import ( "testing" "github.com/rivo/tview" - "github.com/rvolykh/vui/internal/config" - "github.com/rvolykh/vui/internal/vault" - "github.com/sirupsen/logrus" + "github.com/rvolykh/vui/internal/models" "github.com/stretchr/testify/assert" ) func TestNewSecretsTree(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() + fixtures := WithFixtures(t) - st := NewSecretsTree(cfg, vaultMgr, logger, app) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, fixtures.logger, fixtures.app) assert.NotNil(t, st) - assert.Equal(t, cfg, st.config) - assert.Equal(t, vaultMgr, st.vaultMgr) - assert.Equal(t, logger, st.logger) - assert.Equal(t, app, st.app) + assert.Equal(t, fixtures.cfg, st.config) + assert.Equal(t, fixtures.interactor, st.interactor) + assert.Equal(t, fixtures.logger, st.logger) + assert.Equal(t, fixtures.app, st.app) assert.NotNil(t, st.formsMgr) assert.NotNil(t, st.clipboardHandler) assert.Nil(t, st.tree) // Not initialized yet @@ -30,56 +25,45 @@ func TestNewSecretsTree(t *testing.T) { } func TestNewSecretsTree_WithNilConfig(t *testing.T) { - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() + fixtures := WithFixtures(t) - st := NewSecretsTree(nil, vaultMgr, logger, app) + st := NewSecretsTree(nil, fixtures.interactor, fixtures.logger, fixtures.app) assert.NotNil(t, st) assert.Nil(t, st.config) } func TestNewSecretsTree_WithNilVaultManager(t *testing.T) { - cfg := &config.Config{} - logger := logrus.New() - app := tview.NewApplication() + fixtures := WithFixtures(t) - st := NewSecretsTree(cfg, nil, logger, app) + st := NewSecretsTree(fixtures.cfg, nil, fixtures.logger, fixtures.app) assert.NotNil(t, st) - assert.Nil(t, st.vaultMgr) + assert.Nil(t, st.interactor) } func TestNewSecretsTree_WithNilLogger(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - app := tview.NewApplication() + fixtures := WithFixtures(t) - st := NewSecretsTree(cfg, vaultMgr, nil, app) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, nil, fixtures.app) assert.NotNil(t, st) assert.Nil(t, st.logger) } func TestNewSecretsTree_WithNilApp(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() + fixtures := WithFixtures(t) - st := NewSecretsTree(cfg, vaultMgr, logger, nil) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, fixtures.logger, nil) assert.NotNil(t, st) assert.Nil(t, st.app) } func TestSecretsTree_Initialize(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() + fixtures := WithFixtures(t) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, fixtures.logger, fixtures.app) - st := NewSecretsTree(cfg, vaultMgr, logger, app) err := st.Initialize() assert.NoError(t, err) @@ -88,12 +72,9 @@ func TestSecretsTree_Initialize(t *testing.T) { } func TestSecretsTree_Initialize_CreatesTree(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() + fixtures := WithFixtures(t) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, fixtures.logger, fixtures.app) - st := NewSecretsTree(cfg, vaultMgr, logger, app) err := st.Initialize() assert.NoError(t, err) @@ -105,12 +86,8 @@ func TestSecretsTree_Initialize_CreatesTree(t *testing.T) { } func TestSecretsTree_Initialize_MultipleCallsSafe(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() - - st := NewSecretsTree(cfg, vaultMgr, logger, app) + fixtures := WithFixtures(t) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, fixtures.logger, fixtures.app) // Initialize multiple times should not panic err1 := st.Initialize() @@ -124,12 +101,8 @@ func TestSecretsTree_Initialize_MultipleCallsSafe(t *testing.T) { } func TestSecretsTree_GetPrimitive(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() - - st := NewSecretsTree(cfg, vaultMgr, logger, app) + fixtures := WithFixtures(t) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, fixtures.logger, fixtures.app) st.Initialize() primitive := st.GetPrimitive() @@ -139,12 +112,8 @@ func TestSecretsTree_GetPrimitive(t *testing.T) { } func TestSecretsTree_GetPrimitive_BeforeInitialize(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() - - st := NewSecretsTree(cfg, vaultMgr, logger, app) + fixtures := WithFixtures(t) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, fixtures.logger, fixtures.app) primitive := st.GetPrimitive() @@ -152,15 +121,11 @@ func TestSecretsTree_GetPrimitive_BeforeInitialize(t *testing.T) { } func TestSecretsTree_SetSelectionHandler(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() - - st := NewSecretsTree(cfg, vaultMgr, logger, app) + fixtures := WithFixtures(t) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, fixtures.logger, fixtures.app) handlerCalled := false - handler := func(secret *vault.SecretNode, key string) { + handler := func(secret *models.SecretNode, key string) { handlerCalled = true } @@ -174,12 +139,8 @@ func TestSecretsTree_SetSelectionHandler(t *testing.T) { } func TestSecretsTree_SetSelectionHandler_Nil(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() - - st := NewSecretsTree(cfg, vaultMgr, logger, app) + fixtures := WithFixtures(t) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, fixtures.logger, fixtures.app) // Should not panic with nil handler st.SetSelectionHandler(nil) @@ -188,12 +149,8 @@ func TestSecretsTree_SetSelectionHandler_Nil(t *testing.T) { } func TestSecretsTree_SetRefreshHandler(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() - - st := NewSecretsTree(cfg, vaultMgr, logger, app) + fixtures := WithFixtures(t) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, fixtures.logger, fixtures.app) handlerCalled := false handler := func() { @@ -210,12 +167,8 @@ func TestSecretsTree_SetRefreshHandler(t *testing.T) { } func TestSecretsTree_SetRefreshHandler_Nil(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() - - st := NewSecretsTree(cfg, vaultMgr, logger, app) + fixtures := WithFixtures(t) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, fixtures.logger, fixtures.app) // Should not panic with nil handler st.SetRefreshHandler(nil) @@ -224,12 +177,8 @@ func TestSecretsTree_SetRefreshHandler_Nil(t *testing.T) { } func TestSecretsTree_SetModalHandler(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() - - st := NewSecretsTree(cfg, vaultMgr, logger, app) + fixtures := WithFixtures(t) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, fixtures.logger, fixtures.app) handlerCalled := false handler := func(primitive tview.Primitive, show bool) { @@ -246,12 +195,8 @@ func TestSecretsTree_SetModalHandler(t *testing.T) { } func TestSecretsTree_SetModalHandler_Nil(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() - - st := NewSecretsTree(cfg, vaultMgr, logger, app) + fixtures := WithFixtures(t) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, fixtures.logger, fixtures.app) // Should not panic with nil handler st.SetModalHandler(nil) @@ -260,12 +205,8 @@ func TestSecretsTree_SetModalHandler_Nil(t *testing.T) { } func TestSecretsTree_SetValuePanel(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() - - st := NewSecretsTree(cfg, vaultMgr, logger, app) + fixtures := WithFixtures(t) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, fixtures.logger, fixtures.app) valuePanel := &SecretsValue{} st.SetValuePanel(valuePanel) @@ -275,12 +216,8 @@ func TestSecretsTree_SetValuePanel(t *testing.T) { } func TestSecretsTree_SetValuePanel_Nil(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() - - st := NewSecretsTree(cfg, vaultMgr, logger, app) + fixtures := WithFixtures(t) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, fixtures.logger, fixtures.app) // Should not panic with nil value panel st.SetValuePanel(nil) @@ -289,12 +226,8 @@ func TestSecretsTree_SetValuePanel_Nil(t *testing.T) { } func TestSecretsTree_SetDialogService(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() - - st := NewSecretsTree(cfg, vaultMgr, logger, app) + fixtures := WithFixtures(t) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, fixtures.logger, fixtures.app) // Mock dialog service mockDialogSvc := &mockDialogService{} @@ -304,12 +237,8 @@ func TestSecretsTree_SetDialogService(t *testing.T) { } func TestSecretsTree_SetDialogService_Nil(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() - - st := NewSecretsTree(cfg, vaultMgr, logger, app) + fixtures := WithFixtures(t) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, fixtures.logger, fixtures.app) // Should not panic with nil dialog service st.SetDialogService(nil) @@ -318,12 +247,8 @@ func TestSecretsTree_SetDialogService_Nil(t *testing.T) { } func TestSecretsTree_ShowModal(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() - - st := NewSecretsTree(cfg, vaultMgr, logger, app) + fixtures := WithFixtures(t) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, fixtures.logger, fixtures.app) handlerCalled := false var receivedPrimitive tview.Primitive @@ -344,24 +269,16 @@ func TestSecretsTree_ShowModal(t *testing.T) { } func TestSecretsTree_ShowModal_WithoutHandler(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() - - st := NewSecretsTree(cfg, vaultMgr, logger, app) + fixtures := WithFixtures(t) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, fixtures.logger, fixtures.app) // Should not panic without modal handler st.showModal(nil, false) } func TestSecretsTree_CreateEmptyTree(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() - - st := NewSecretsTree(cfg, vaultMgr, logger, app) + fixtures := WithFixtures(t) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, fixtures.logger, fixtures.app) st.tree = tview.NewTreeView() err := st.createEmptyTree("Test message") @@ -372,12 +289,8 @@ func TestSecretsTree_CreateEmptyTree(t *testing.T) { } func TestSecretsTree_CreateEmptyTree_WithDifferentMessages(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() - - st := NewSecretsTree(cfg, vaultMgr, logger, app) + fixtures := WithFixtures(t) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, fixtures.logger, fixtures.app) st.tree = tview.NewTreeView() messages := []string{ @@ -395,12 +308,8 @@ func TestSecretsTree_CreateEmptyTree_WithDifferentMessages(t *testing.T) { } func TestSecretsTree_FindParentNode_NilRootNode(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() - - st := NewSecretsTree(cfg, vaultMgr, logger, app) + fixtures := WithFixtures(t) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, fixtures.logger, fixtures.app) st.rootNode = nil node := tview.NewTreeNode("test") @@ -410,12 +319,8 @@ func TestSecretsTree_FindParentNode_NilRootNode(t *testing.T) { } func TestSecretsTree_FindParentNode_SimpleTree(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() - - st := NewSecretsTree(cfg, vaultMgr, logger, app) + fixtures := WithFixtures(t) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, fixtures.logger, fixtures.app) // Create a simple tree structure root := tview.NewTreeNode("root") @@ -431,12 +336,8 @@ func TestSecretsTree_FindParentNode_SimpleTree(t *testing.T) { } func TestSecretsTree_FindParentNode_NotFound(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() - - st := NewSecretsTree(cfg, vaultMgr, logger, app) + fixtures := WithFixtures(t) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, fixtures.logger, fixtures.app) // Create a tree root := tview.NewTreeNode("root") @@ -451,12 +352,8 @@ func TestSecretsTree_FindParentNode_NotFound(t *testing.T) { } func TestSecretsTree_FindParentNode_NestedTree(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() - - st := NewSecretsTree(cfg, vaultMgr, logger, app) + fixtures := WithFixtures(t) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, fixtures.logger, fixtures.app) // Create a nested tree structure root := tview.NewTreeNode("root") @@ -478,12 +375,8 @@ func TestSecretsTree_FindParentNode_NestedTree(t *testing.T) { } func TestSecretsTree_Initialize_SetsUpTreeView(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() - - st := NewSecretsTree(cfg, vaultMgr, logger, app) + fixtures := WithFixtures(t) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, fixtures.logger, fixtures.app) st.Initialize() // TreeView should be configured @@ -495,15 +388,11 @@ func TestSecretsTree_Initialize_SetsUpTreeView(t *testing.T) { } func TestSecretsTree_ToggleValueMasking_WithValuePanel(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() - - st := NewSecretsTree(cfg, vaultMgr, logger, app) + fixtures := WithFixtures(t) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, fixtures.logger, fixtures.app) // Create a value panel - mockValuePanel := NewSecretsValue(cfg, vaultMgr, logger) + mockValuePanel := NewSecretsValue(fixtures.cfg, fixtures.interactor, fixtures.logger) mockValuePanel.Initialize() st.SetValuePanel(mockValuePanel) @@ -512,24 +401,16 @@ func TestSecretsTree_ToggleValueMasking_WithValuePanel(t *testing.T) { } func TestSecretsTree_ToggleValueMasking_WithoutValuePanel(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() - - st := NewSecretsTree(cfg, vaultMgr, logger, app) + fixtures := WithFixtures(t) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, fixtures.logger, fixtures.app) // Should not panic without value panel st.toggleValueMasking() } func TestSecretsTree_CopySecretValue_NoNode(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() - - st := NewSecretsTree(cfg, vaultMgr, logger, app) + fixtures := WithFixtures(t) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, fixtures.logger, fixtures.app) st.Initialize() // Should not panic with no node selected @@ -537,12 +418,8 @@ func TestSecretsTree_CopySecretValue_NoNode(t *testing.T) { } func TestSecretsTree_CreateSecret_NoNode(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() - - st := NewSecretsTree(cfg, vaultMgr, logger, app) + fixtures := WithFixtures(t) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, fixtures.logger, fixtures.app) st.Initialize() // Should not panic with no node selected @@ -550,12 +427,8 @@ func TestSecretsTree_CreateSecret_NoNode(t *testing.T) { } func TestSecretsTree_EditSecret_NoNode(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() - - st := NewSecretsTree(cfg, vaultMgr, logger, app) + fixtures := WithFixtures(t) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, fixtures.logger, fixtures.app) st.Initialize() // Should not panic with no node selected @@ -563,12 +436,8 @@ func TestSecretsTree_EditSecret_NoNode(t *testing.T) { } func TestSecretsTree_DeleteSecret_NoNode(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() - - st := NewSecretsTree(cfg, vaultMgr, logger, app) + fixtures := WithFixtures(t) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, fixtures.logger, fixtures.app) st.Initialize() // Should not panic with no node selected @@ -576,12 +445,8 @@ func TestSecretsTree_DeleteSecret_NoNode(t *testing.T) { } func TestSecretsTree_HandleNodeChanged_NilReference(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() - - st := NewSecretsTree(cfg, vaultMgr, logger, app) + fixtures := WithFixtures(t) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, fixtures.logger, fixtures.app) node := tview.NewTreeNode("test") // No reference set @@ -591,12 +456,8 @@ func TestSecretsTree_HandleNodeChanged_NilReference(t *testing.T) { } func TestSecretsTree_HandleNodeSelection_NilReference(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() - - st := NewSecretsTree(cfg, vaultMgr, logger, app) + fixtures := WithFixtures(t) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, fixtures.logger, fixtures.app) node := tview.NewTreeNode("test") // No reference set @@ -606,15 +467,11 @@ func TestSecretsTree_HandleNodeSelection_NilReference(t *testing.T) { } func TestSecretsTree_AllSettersCalled(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() - - st := NewSecretsTree(cfg, vaultMgr, logger, app) + fixtures := WithFixtures(t) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, fixtures.logger, fixtures.app) // Call all setters - st.SetSelectionHandler(func(*vault.SecretNode, string) {}) + st.SetSelectionHandler(func(*models.SecretNode, string) {}) st.SetRefreshHandler(func() {}) st.SetModalHandler(func(tview.Primitive, bool) {}) st.SetValuePanel(&SecretsValue{}) @@ -628,12 +485,8 @@ func TestSecretsTree_AllSettersCalled(t *testing.T) { } func TestSecretsTree_InitializeCreatesEmptyTree(t *testing.T) { - cfg := &config.Config{} - vaultMgr := &vault.Manager{} - logger := logrus.New() - app := tview.NewApplication() - - st := NewSecretsTree(cfg, vaultMgr, logger, app) + fixtures := WithFixtures(t) + st := NewSecretsTree(fixtures.cfg, fixtures.interactor, fixtures.logger, fixtures.app) err := st.Initialize() assert.NoError(t, err) diff --git a/internal/ui/panels/secrets_value.go b/internal/ui/panels/secrets_value.go index 319a607..b0882ed 100644 --- a/internal/ui/panels/secrets_value.go +++ b/internal/ui/panels/secrets_value.go @@ -6,29 +6,30 @@ import ( "strings" "github.com/rivo/tview" + "github.com/rvolykh/vui/internal/backend" "github.com/rvolykh/vui/internal/config" - "github.com/rvolykh/vui/internal/vault" + "github.com/rvolykh/vui/internal/models" "github.com/sirupsen/logrus" ) // SecretsValue represents the secret value display panel type SecretsValue struct { config *config.Config - vaultMgr *vault.Manager + interactor backend.Interactor textView *tview.TextView - currentSecret *vault.SecretNode + currentSecret *models.SecretNode currentKey string isMasked bool // Track if values are currently masked logger *logrus.Logger } // NewSecretsValue creates a new secrets value panel -func NewSecretsValue(config *config.Config, vaultMgr *vault.Manager, logger *logrus.Logger) *SecretsValue { +func NewSecretsValue(config *config.Config, interactor backend.Interactor, logger *logrus.Logger) *SecretsValue { return &SecretsValue{ - config: config, - vaultMgr: vaultMgr, - isMasked: !config.UI.ShowHiddenSecrets, - logger: logger, + config: config, + interactor: interactor, + isMasked: !config.UI.ShowHiddenSecrets, + logger: logger, } } @@ -53,7 +54,7 @@ func (vp *SecretsValue) Initialize() error { } // ShowSecret displays all key-value pairs of a secret -func (vp *SecretsValue) ShowSecret(secret *vault.SecretNode) { +func (vp *SecretsValue) ShowSecret(secret *models.SecretNode) { vp.currentSecret = secret vp.currentKey = "" vp.isMasked = !vp.config.UI.ShowHiddenSecrets // Reset to masked when showing a new secret @@ -61,7 +62,7 @@ func (vp *SecretsValue) ShowSecret(secret *vault.SecretNode) { } // ShowKey displays a specific key's value from a secret -func (vp *SecretsValue) ShowKey(secret *vault.SecretNode, key string) { +func (vp *SecretsValue) ShowKey(secret *models.SecretNode, key string) { vp.currentSecret = secret vp.currentKey = key vp.isMasked = !vp.config.UI.ShowHiddenSecrets // Reset to masked when showing a new key @@ -80,7 +81,7 @@ Select a secret to view its contents.[white]`, path) } // displaySecretData displays all key-value pairs of a secret -func (vp *SecretsValue) displaySecretData(secret *vault.SecretNode) { +func (vp *SecretsValue) displaySecretData(secret *models.SecretNode) { var content strings.Builder content.WriteString("[yellow]Secret Data:[white]\n\n") @@ -115,7 +116,7 @@ func (vp *SecretsValue) displaySecretData(secret *vault.SecretNode) { } // displayKeyValue displays a specific key's value -func (vp *SecretsValue) displayKeyValue(secret *vault.SecretNode, key string) { +func (vp *SecretsValue) displayKeyValue(secret *models.SecretNode, key string) { var content strings.Builder content.WriteString(fmt.Sprintf("[yellow]Key:[white] [green]%s[white]\n\n", key)) diff --git a/internal/ui/panels/secrets_value_test.go b/internal/ui/panels/secrets_value_test.go index 2c004f8..31d1743 100644 --- a/internal/ui/panels/secrets_value_test.go +++ b/internal/ui/panels/secrets_value_test.go @@ -4,29 +4,23 @@ import ( "testing" "time" - "github.com/rvolykh/vui/internal/config" - "github.com/rvolykh/vui/internal/vault" - "github.com/sirupsen/logrus" + "github.com/rvolykh/vui/internal/models" "github.com/stretchr/testify/assert" ) func TestNewSecretsValue(t *testing.T) { - cfg := &config.Config{} - logger := logrus.New() - vaultMgr, _ := vault.NewManager(cfg, logger) + fixtures := WithFixtures(t) - panel := NewSecretsValue(cfg, vaultMgr, logger) + panel := NewSecretsValue(fixtures.cfg, fixtures.interactor, fixtures.logger) assert.NotNil(t, panel) assert.True(t, panel.isMasked) // Should start masked } func TestSecretsValue_Initialize(t *testing.T) { - cfg := &config.Config{} - logger := logrus.New() - vaultMgr, _ := vault.NewManager(cfg, logger) + fixtures := WithFixtures(t) + panel := NewSecretsValue(fixtures.cfg, fixtures.interactor, fixtures.logger) - panel := NewSecretsValue(cfg, vaultMgr, logger) err := panel.Initialize() assert.NoError(t, err) @@ -34,14 +28,10 @@ func TestSecretsValue_Initialize(t *testing.T) { } func TestSecretsValue_ShowSecret(t *testing.T) { - cfg := &config.Config{} - logger := logrus.New() - vaultMgr, _ := vault.NewManager(cfg, logger) - - panel := NewSecretsValue(cfg, vaultMgr, logger) + fixtures := WithFixtures(t) + panel := NewSecretsValue(fixtures.cfg, fixtures.interactor, fixtures.logger) panel.Initialize() - - secret := &vault.SecretNode{ + secret := &models.SecretNode{ Name: "test-secret", Path: "secrets/test", IsSecret: true, @@ -59,14 +49,10 @@ func TestSecretsValue_ShowSecret(t *testing.T) { } func TestSecretsValue_ShowKey(t *testing.T) { - cfg := &config.Config{} - logger := logrus.New() - vaultMgr, _ := vault.NewManager(cfg, logger) - - panel := NewSecretsValue(cfg, vaultMgr, logger) + fixtures := WithFixtures(t) + panel := NewSecretsValue(fixtures.cfg, fixtures.interactor, fixtures.logger) panel.Initialize() - - secret := &vault.SecretNode{ + secret := &models.SecretNode{ Name: "test-secret", Path: "secrets/test", IsSecret: true, @@ -83,21 +69,17 @@ func TestSecretsValue_ShowKey(t *testing.T) { } func TestSecretsValue_ToggleMasking(t *testing.T) { - cfg := &config.Config{} - logger := logrus.New() - vaultMgr, _ := vault.NewManager(cfg, logger) - - panel := NewSecretsValue(cfg, vaultMgr, logger) + fixtures := WithFixtures(t) + panel := NewSecretsValue(fixtures.cfg, fixtures.interactor, fixtures.logger) panel.Initialize() - - secret := &vault.SecretNode{ + secret := &models.SecretNode{ Name: "test-secret", Path: "secrets/test", IsSecret: true, Data: map[string]interface{}{ "password": "secret123", }, - Metadata: &vault.SecretMetadata{ + Metadata: &models.SecretMetadata{ Version: 1, CreatedTime: time.Now(), }, @@ -114,11 +96,8 @@ func TestSecretsValue_ToggleMasking(t *testing.T) { } func TestSecretsValue_ShowDirectory(t *testing.T) { - cfg := &config.Config{} - logger := logrus.New() - vaultMgr, _ := vault.NewManager(cfg, logger) - - panel := NewSecretsValue(cfg, vaultMgr, logger) + fixtures := WithFixtures(t) + panel := NewSecretsValue(fixtures.cfg, fixtures.interactor, fixtures.logger) panel.Initialize() panel.ShowDirectory("secrets/test") @@ -128,11 +107,8 @@ func TestSecretsValue_ShowDirectory(t *testing.T) { } func TestSecretsValue_FormatValue(t *testing.T) { - cfg := &config.Config{} - logger := logrus.New() - vaultMgr, _ := vault.NewManager(cfg, logger) - - panel := NewSecretsValue(cfg, vaultMgr, logger) + fixtures := WithFixtures(t) + panel := NewSecretsValue(fixtures.cfg, fixtures.interactor, fixtures.logger) tests := []struct { name string diff --git a/internal/vault/client.go b/internal/vault/client.go deleted file mode 100644 index 866c841..0000000 --- a/internal/vault/client.go +++ /dev/null @@ -1,172 +0,0 @@ -package vault - -import ( - "context" - "fmt" - "time" - - "github.com/hashicorp/vault/api" - "github.com/rvolykh/vui/internal/config" - "github.com/sirupsen/logrus" -) - -// Client wraps the Vault API client with additional functionality -type Client struct { - apiClient *api.Client - profile *config.VaultProfile - logger *logrus.Logger -} - -// TestConnection tests the connection to the vault -func (c *Client) TestConnection() error { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - // Try to get the vault status - status, err := c.apiClient.Sys().SealStatusWithContext(ctx) - if err != nil { - return fmt.Errorf("failed to get vault status: %w", err) - } - - c.logger.Infof("Connected to vault at %s (sealed: %v)", c.apiClient.Address(), status.Sealed) - return nil -} - -// GetSecret retrieves a secret from the vault -func (c *Client) GetSecret(path string) (map[string]interface{}, error) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - secret, err := c.apiClient.Logical().ReadWithContext(ctx, "secret/data/"+path) - if err != nil { - return nil, fmt.Errorf("failed to get secret at path '%s': %w", path, err) - } - - if secret == nil || secret.Data == nil { - return nil, fmt.Errorf("secret not found at path '%s'", path) - } - - // For KV v2, the actual data is nested under "data" - if data, ok := secret.Data["data"].(map[string]interface{}); ok { - return data, nil - } - - return secret.Data, nil -} - -// ListSecrets lists secrets at a given path -func (c *Client) ListSecrets(path string) ([]string, error) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - secret, err := c.apiClient.Logical().ListWithContext(ctx, "secret/metadata/"+path) - if err != nil { - return nil, fmt.Errorf("failed to list secrets at path '%s': %w", path, err) - } - - if secret == nil || secret.Data == nil { - return []string{}, nil - } - - // Extract keys from the response - if keys, ok := secret.Data["keys"].([]interface{}); ok { - result := make([]string, len(keys)) - for i, key := range keys { - if keyStr, ok := key.(string); ok { - result[i] = keyStr - } - } - return result, nil - } - - return []string{}, nil -} - -// CreateSecret creates a new secret in the vault -func (c *Client) CreateSecret(path string, data map[string]interface{}) error { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - // For KV v2, we need to wrap the data - secretData := map[string]interface{}{ - "data": data, - } - - _, err := c.apiClient.Logical().WriteWithContext(ctx, "secret/data/"+path, secretData) - if err != nil { - return fmt.Errorf("failed to create secret at path '%s': %w", path, err) - } - - c.logger.Infof("Created secret at path: %s", path) - return nil -} - -// UpdateSecret updates an existing secret in the vault -func (c *Client) UpdateSecret(path string, data map[string]interface{}) error { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - // For KV v2, we need to wrap the data - secretData := map[string]interface{}{ - "data": data, - } - - _, err := c.apiClient.Logical().WriteWithContext(ctx, "secret/data/"+path, secretData) - if err != nil { - return fmt.Errorf("failed to update secret at path '%s': %w", path, err) - } - - c.logger.Infof("Updated secret at path: %s", path) - return nil -} - -// DeleteSecret deletes a secret from the vault -func (c *Client) DeleteSecret(path string) error { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - _, err := c.apiClient.Logical().DeleteWithContext(ctx, "secret/metadata/"+path) - if err != nil { - return fmt.Errorf("failed to delete secret at path '%s': %w", path, err) - } - - c.logger.Infof("Deleted secret at path: %s", path) - return nil -} - -// GetVaultInfo returns information about the vault -func (c *Client) GetVaultInfo() (*VaultInfo, error) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - status, err := c.apiClient.Sys().SealStatusWithContext(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get vault status: %w", err) - } - - health, err := c.apiClient.Sys().HealthWithContext(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get vault health: %w", err) - } - - return &VaultInfo{ - Address: c.apiClient.Address(), - Sealed: status.Sealed, - Version: status.Version, - ClusterID: status.ClusterID, - ClusterName: status.ClusterName, - Initialized: health.Initialized, - Standby: health.Standby, - }, nil -} - -// VaultInfo contains information about the vault -type VaultInfo struct { - Address string `json:"address"` - Sealed bool `json:"sealed"` - Version string `json:"version"` - ClusterID string `json:"cluster_id"` - ClusterName string `json:"cluster_name"` - Initialized bool `json:"initialized"` - Standby bool `json:"standby"` -} diff --git a/internal/vault/connection.go b/internal/vault/connection.go deleted file mode 100644 index 4764db4..0000000 --- a/internal/vault/connection.go +++ /dev/null @@ -1,268 +0,0 @@ -package vault - -import ( - "context" - "fmt" - "sync" - "time" - - "github.com/sirupsen/logrus" -) - -var ( - connectionTimeout = 10 * time.Second -) - -// ConnectionStatus represents the status of a vault connection -type ConnectionStatus struct { - Connecting bool `json:"connecting"` - Connected bool `json:"connected"` - Address string `json:"address"` - Sealed bool `json:"sealed"` - Initialized bool `json:"initialized"` - Version string `json:"version"` - ClusterID string `json:"cluster_id"` - LastCheck time.Time `json:"last_check"` - Error string `json:"error,omitempty"` -} - -// ConnectionManager manages vault connections and their status -type ConnectionManager struct { - clients map[string]*Client - status map[string]*ConnectionStatus - mutex sync.RWMutex - logger *logrus.Logger -} - -// NewConnectionManager creates a new connection manager -func NewConnectionManager(logger *logrus.Logger) *ConnectionManager { - return &ConnectionManager{ - clients: make(map[string]*Client), - status: make(map[string]*ConnectionStatus), - logger: logger, - } -} - -// AddConnection adds a new vault connection and sets its status to connecting -func (cm *ConnectionManager) AddConnection(name string, client *Client) { - cm.mutex.Lock() - defer cm.mutex.Unlock() - - cm.clients[name] = client - cm.status[name] = &ConnectionStatus{ - Connecting: true, - Address: client.apiClient.Address(), - LastCheck: time.Now(), - } -} - -// TestConnectionAsync tests a vault connection asynchronously -func (cm *ConnectionManager) TestConnectionAsync(name string) { - cm.mutex.RLock() - client, exists := cm.clients[name] - cm.mutex.RUnlock() - - if !exists { - return - } - - go func() { - status, err := cm.testConnection(client) - if err != nil { - cm.logger.Warnf("Failed to connect to vault '%s': %v", name, err) - cm.mutex.Lock() - if existingStatus, ok := cm.status[name]; ok { - existingStatus.Connecting = false - existingStatus.Connected = false - existingStatus.Error = err.Error() - existingStatus.LastCheck = time.Now() - } - cm.mutex.Unlock() - return - } - - cm.mutex.Lock() - cm.status[name] = status - cm.status[name].Connecting = false - cm.mutex.Unlock() - cm.logger.Infof("Updated vault connection status: %s (connected: %v)", name, status.Connected) - }() -} - -// RemoveConnection removes a vault connection -func (cm *ConnectionManager) RemoveConnection(name string) { - cm.mutex.Lock() - defer cm.mutex.Unlock() - - delete(cm.clients, name) - delete(cm.status, name) - cm.logger.Infof("Removed vault connection: %s", name) -} - -// GetConnection returns a connection by name -func (cm *ConnectionManager) GetConnection(name string) (*Client, error) { - cm.mutex.RLock() - defer cm.mutex.RUnlock() - - client, exists := cm.clients[name] - if !exists { - return nil, fmt.Errorf("connection '%s' not found", name) - } - - return client, nil -} - -// GetConnectionStatus returns the status of a connection -func (cm *ConnectionManager) GetConnectionStatus(name string) (*ConnectionStatus, error) { - cm.mutex.RLock() - defer cm.mutex.RUnlock() - - status, exists := cm.status[name] - if !exists { - return nil, fmt.Errorf("connection '%s' not found", name) - } - - // Return a copy to prevent race conditions - statusCopy := *status - return &statusCopy, nil -} - -// ListConnections returns all connection names -func (cm *ConnectionManager) ListConnections() []string { - cm.mutex.RLock() - defer cm.mutex.RUnlock() - - connections := make([]string, 0, len(cm.clients)) - for name := range cm.clients { - connections = append(connections, name) - } - - return connections -} - -// RefreshConnectionStatus refreshes the status of a specific connection -func (cm *ConnectionManager) RefreshConnectionStatus(name string) error { - cm.mutex.Lock() - defer cm.mutex.Unlock() - - client, exists := cm.clients[name] - if !exists { - return fmt.Errorf("connection '%s' not found", name) - } - - status, err := cm.testConnection(client) - if err != nil { - // Update status with error - if existingStatus, ok := cm.status[name]; ok { - existingStatus.Connected = false - existingStatus.Error = err.Error() - existingStatus.LastCheck = time.Now() - } - return err - } - - cm.status[name] = status - return nil -} - -// RefreshAllConnections refreshes the status of all connections -func (cm *ConnectionManager) RefreshAllConnections() { - cm.mutex.RLock() - names := make([]string, 0, len(cm.clients)) - for name := range cm.clients { - names = append(names, name) - } - cm.mutex.RUnlock() - - for _, name := range names { - cm.mutex.RLock() - status, ok := cm.status[name] - cm.mutex.RUnlock() - - if ok && !status.Connected && !status.Connecting { - // Do not refresh connections that have failed, unless manually triggered - continue - } - cm.TestConnectionAsync(name) - } -} - -// SetAllConnecting sets all connections to "Connecting" state -func (cm *ConnectionManager) SetAllConnecting() { - cm.mutex.Lock() - defer cm.mutex.Unlock() - - for name, status := range cm.status { - status.Connecting = true - status.Error = "" - cm.logger.Debugf("Set connection '%s' to connecting state", name) - } -} - -// testConnection tests a vault connection and returns its status -func (cm *ConnectionManager) testConnection(client *Client) (*ConnectionStatus, error) { - ctx, cancel := context.WithTimeout(context.Background(), connectionTimeout) - defer cancel() - - // Get vault status - status, err := client.apiClient.Sys().SealStatusWithContext(ctx) - if err != nil { - return &ConnectionStatus{ - Connected: false, - Address: client.apiClient.Address(), - Error: err.Error(), - LastCheck: time.Now(), - }, err - } - - // Get vault health - health, err := client.apiClient.Sys().HealthWithContext(ctx) - if err != nil { - return &ConnectionStatus{ - Connected: false, - Address: client.apiClient.Address(), - Error: err.Error(), - LastCheck: time.Now(), - }, err - } - - return &ConnectionStatus{ - Connected: true, - Address: client.apiClient.Address(), - Sealed: status.Sealed, - Initialized: health.Initialized, - Version: status.Version, - ClusterID: status.ClusterID, - LastCheck: time.Now(), - }, nil -} - -// GetHealthyConnections returns connections that are healthy and connected -func (cm *ConnectionManager) GetHealthyConnections() []string { - cm.mutex.RLock() - defer cm.mutex.RUnlock() - - var healthy []string - for name, status := range cm.status { - if status.Connected && !status.Sealed && status.Initialized { - healthy = append(healthy, name) - } - } - - return healthy -} - -// GetConnectedConnections returns all connections that are connected (regardless of sealed/initialized status) -func (cm *ConnectionManager) GetConnectedConnections() []string { - cm.mutex.RLock() - defer cm.mutex.RUnlock() - - var connected []string - for name, status := range cm.status { - if status.Connected { - connected = append(connected, name) - } - } - - return connected -} diff --git a/internal/vault/connection_test.go b/internal/vault/connection_test.go deleted file mode 100644 index 1548bc0..0000000 --- a/internal/vault/connection_test.go +++ /dev/null @@ -1,955 +0,0 @@ -package vault - -import ( - "testing" - "time" - - "github.com/hashicorp/vault/api" - "github.com/rvolykh/vui/internal/config" - "github.com/sirupsen/logrus" -) - -func TestNewConnectionManager(t *testing.T) { - t.Run("creates connection manager with logger", func(t *testing.T) { - logger := logrus.New() - cm := NewConnectionManager(logger) - - if cm == nil { - t.Fatal("Expected connection manager to be created") - } - - if cm.logger != logger { - t.Error("Expected logger to be set") - } - - if cm.clients == nil { - t.Error("Expected clients map to be initialized") - } - - if cm.status == nil { - t.Error("Expected status map to be initialized") - } - - if len(cm.clients) != 0 { - t.Errorf("Expected clients map to be empty, got %d entries", len(cm.clients)) - } - - if len(cm.status) != 0 { - t.Errorf("Expected status map to be empty, got %d entries", len(cm.status)) - } - }) -} - -func TestConnectionManager_AddConnection(t *testing.T) { - t.Run("adds connection successfully", func(t *testing.T) { - logger := logrus.New() - logger.SetLevel(logrus.ErrorLevel) - cm := NewConnectionManager(logger) - - profile := &config.VaultProfile{ - Address: "http://localhost:8200", - AuthMethod: "token", - AuthConfig: config.AuthConfig{Token: "test-token"}, - } - client := createTestClient(t, profile) - - cm.AddConnection("test-vault", client) - - if len(cm.clients) != 1 { - t.Errorf("Expected 1 client, got %d", len(cm.clients)) - } - - if len(cm.status) != 1 { - t.Errorf("Expected 1 status entry, got %d", len(cm.status)) - } - - retrievedClient, exists := cm.clients["test-vault"] - if !exists { - t.Fatal("Expected client to exist") - } - - if retrievedClient != client { - t.Error("Expected to retrieve the same client") - } - - status, exists := cm.status["test-vault"] - if !exists { - t.Fatal("Expected status to exist") - } - - if !status.Connecting { - t.Error("Expected status to be connecting") - } - - if status.Address != "http://localhost:8200" { - t.Errorf("Expected address 'http://localhost:8200', got '%s'", status.Address) - } - }) - - t.Run("overwrites existing connection", func(t *testing.T) { - logger := logrus.New() - logger.SetLevel(logrus.ErrorLevel) - cm := NewConnectionManager(logger) - - profile1 := &config.VaultProfile{ - Address: "http://localhost:8200", - AuthMethod: "token", - AuthConfig: config.AuthConfig{Token: "token1"}, - } - client1 := createTestClient(t, profile1) - - profile2 := &config.VaultProfile{ - Address: "http://localhost:8201", - AuthMethod: "token", - AuthConfig: config.AuthConfig{Token: "token2"}, - } - client2 := createTestClient(t, profile2) - - cm.AddConnection("test-vault", client1) - cm.AddConnection("test-vault", client2) - - if len(cm.clients) != 1 { - t.Errorf("Expected 1 client, got %d", len(cm.clients)) - } - - retrievedClient := cm.clients["test-vault"] - if retrievedClient != client2 { - t.Error("Expected second client to overwrite first") - } - - status := cm.status["test-vault"] - if status.Address != "http://localhost:8201" { - t.Errorf("Expected address 'http://localhost:8201', got '%s'", status.Address) - } - }) -} - -func TestConnectionManager_RemoveConnection(t *testing.T) { - t.Run("removes connection successfully", func(t *testing.T) { - logger := logrus.New() - logger.SetLevel(logrus.ErrorLevel) - cm := NewConnectionManager(logger) - - profile := &config.VaultProfile{ - Address: "http://localhost:8200", - AuthMethod: "token", - AuthConfig: config.AuthConfig{Token: "test-token"}, - } - client := createTestClient(t, profile) - - cm.AddConnection("test-vault", client) - cm.RemoveConnection("test-vault") - - if len(cm.clients) != 0 { - t.Errorf("Expected 0 clients, got %d", len(cm.clients)) - } - - if len(cm.status) != 0 { - t.Errorf("Expected 0 status entries, got %d", len(cm.status)) - } - }) - - t.Run("removing non-existent connection is safe", func(t *testing.T) { - logger := logrus.New() - logger.SetLevel(logrus.ErrorLevel) - cm := NewConnectionManager(logger) - - // Should not panic - cm.RemoveConnection("non-existent") - - if len(cm.clients) != 0 { - t.Errorf("Expected 0 clients, got %d", len(cm.clients)) - } - }) - - t.Run("removes only specified connection", func(t *testing.T) { - logger := logrus.New() - logger.SetLevel(logrus.ErrorLevel) - cm := NewConnectionManager(logger) - - profile1 := &config.VaultProfile{ - Address: "http://localhost:8200", - AuthMethod: "token", - AuthConfig: config.AuthConfig{Token: "token1"}, - } - client1 := createTestClient(t, profile1) - - profile2 := &config.VaultProfile{ - Address: "http://localhost:8201", - AuthMethod: "token", - AuthConfig: config.AuthConfig{Token: "token2"}, - } - client2 := createTestClient(t, profile2) - - cm.AddConnection("vault1", client1) - cm.AddConnection("vault2", client2) - cm.RemoveConnection("vault1") - - if len(cm.clients) != 1 { - t.Errorf("Expected 1 client, got %d", len(cm.clients)) - } - - if _, exists := cm.clients["vault2"]; !exists { - t.Error("Expected vault2 to still exist") - } - - if _, exists := cm.clients["vault1"]; exists { - t.Error("Expected vault1 to be removed") - } - }) -} - -func TestConnectionManager_GetConnection(t *testing.T) { - t.Run("returns connection when it exists", func(t *testing.T) { - logger := logrus.New() - logger.SetLevel(logrus.ErrorLevel) - cm := NewConnectionManager(logger) - - profile := &config.VaultProfile{ - Address: "http://localhost:8200", - AuthMethod: "token", - AuthConfig: config.AuthConfig{Token: "test-token"}, - } - client := createTestClient(t, profile) - - cm.AddConnection("test-vault", client) - - retrievedClient, err := cm.GetConnection("test-vault") - if err != nil { - t.Fatalf("Expected no error, got: %v", err) - } - - if retrievedClient != client { - t.Error("Expected to retrieve the same client") - } - }) - - t.Run("returns error when connection not found", func(t *testing.T) { - logger := logrus.New() - logger.SetLevel(logrus.ErrorLevel) - cm := NewConnectionManager(logger) - - _, err := cm.GetConnection("non-existent") - if err == nil { - t.Error("Expected error when connection not found") - } - - expectedMsg := "connection 'non-existent' not found" - if err.Error() != expectedMsg { - t.Errorf("Expected error message '%s', got '%s'", expectedMsg, err.Error()) - } - }) -} - -func TestConnectionManager_GetConnectionStatus(t *testing.T) { - t.Run("returns status when connection exists", func(t *testing.T) { - logger := logrus.New() - logger.SetLevel(logrus.ErrorLevel) - cm := NewConnectionManager(logger) - - profile := &config.VaultProfile{ - Address: "http://localhost:8200", - AuthMethod: "token", - AuthConfig: config.AuthConfig{Token: "test-token"}, - } - client := createTestClient(t, profile) - - cm.AddConnection("test-vault", client) - - status, err := cm.GetConnectionStatus("test-vault") - if err != nil { - t.Fatalf("Expected no error, got: %v", err) - } - - if status == nil { - t.Fatal("Expected status to be returned") - } - - if !status.Connecting { - t.Error("Expected status to be connecting") - } - - if status.Address != "http://localhost:8200" { - t.Errorf("Expected address 'http://localhost:8200', got '%s'", status.Address) - } - }) - - t.Run("returns copy of status to prevent race conditions", func(t *testing.T) { - logger := logrus.New() - logger.SetLevel(logrus.ErrorLevel) - cm := NewConnectionManager(logger) - - profile := &config.VaultProfile{ - Address: "http://localhost:8200", - AuthMethod: "token", - AuthConfig: config.AuthConfig{Token: "test-token"}, - } - client := createTestClient(t, profile) - - cm.AddConnection("test-vault", client) - - status1, _ := cm.GetConnectionStatus("test-vault") - status2, _ := cm.GetConnectionStatus("test-vault") - - // Modify status1 - status1.Connected = true - status1.Error = "test error" - - // status2 should not be affected - if status2.Connected { - t.Error("Expected status2 to not be affected by changes to status1") - } - - if status2.Error != "" { - t.Error("Expected status2 error to be empty") - } - }) - - t.Run("returns error when connection not found", func(t *testing.T) { - logger := logrus.New() - logger.SetLevel(logrus.ErrorLevel) - cm := NewConnectionManager(logger) - - _, err := cm.GetConnectionStatus("non-existent") - if err == nil { - t.Error("Expected error when connection not found") - } - - expectedMsg := "connection 'non-existent' not found" - if err.Error() != expectedMsg { - t.Errorf("Expected error message '%s', got '%s'", expectedMsg, err.Error()) - } - }) -} - -func TestConnectionManager_ListConnections(t *testing.T) { - t.Run("returns empty list when no connections", func(t *testing.T) { - logger := logrus.New() - logger.SetLevel(logrus.ErrorLevel) - cm := NewConnectionManager(logger) - - connections := cm.ListConnections() - if len(connections) != 0 { - t.Errorf("Expected 0 connections, got %d", len(connections)) - } - }) - - t.Run("returns all connection names", func(t *testing.T) { - logger := logrus.New() - logger.SetLevel(logrus.ErrorLevel) - cm := NewConnectionManager(logger) - - profile1 := &config.VaultProfile{ - Address: "http://localhost:8200", - AuthMethod: "token", - AuthConfig: config.AuthConfig{Token: "token1"}, - } - client1 := createTestClient(t, profile1) - - profile2 := &config.VaultProfile{ - Address: "http://localhost:8201", - AuthMethod: "token", - AuthConfig: config.AuthConfig{Token: "token2"}, - } - client2 := createTestClient(t, profile2) - - profile3 := &config.VaultProfile{ - Address: "http://localhost:8202", - AuthMethod: "token", - AuthConfig: config.AuthConfig{Token: "token3"}, - } - client3 := createTestClient(t, profile3) - - cm.AddConnection("vault1", client1) - cm.AddConnection("vault2", client2) - cm.AddConnection("vault3", client3) - - connections := cm.ListConnections() - if len(connections) != 3 { - t.Errorf("Expected 3 connections, got %d", len(connections)) - } - - connectionMap := make(map[string]bool) - for _, name := range connections { - connectionMap[name] = true - } - - if !connectionMap["vault1"] || !connectionMap["vault2"] || !connectionMap["vault3"] { - t.Error("Expected vault1, vault2, and vault3 to be in the list") - } - }) -} - -func TestConnectionManager_SetAllConnecting(t *testing.T) { - t.Run("sets all connections to connecting state", func(t *testing.T) { - logger := logrus.New() - logger.SetLevel(logrus.ErrorLevel) - cm := NewConnectionManager(logger) - - profile1 := &config.VaultProfile{ - Address: "http://localhost:8200", - AuthMethod: "token", - AuthConfig: config.AuthConfig{Token: "token1"}, - } - client1 := createTestClient(t, profile1) - - profile2 := &config.VaultProfile{ - Address: "http://localhost:8201", - AuthMethod: "token", - AuthConfig: config.AuthConfig{Token: "token2"}, - } - client2 := createTestClient(t, profile2) - - cm.AddConnection("vault1", client1) - cm.AddConnection("vault2", client2) - - // Set some statuses to non-connecting - cm.status["vault1"].Connecting = false - cm.status["vault1"].Connected = true - cm.status["vault2"].Connecting = false - cm.status["vault2"].Error = "some error" - - cm.SetAllConnecting() - - for name, status := range cm.status { - if !status.Connecting { - t.Errorf("Expected %s to be connecting", name) - } - if status.Error != "" { - t.Errorf("Expected %s error to be cleared, got '%s'", name, status.Error) - } - } - }) - - t.Run("does nothing when no connections", func(t *testing.T) { - logger := logrus.New() - logger.SetLevel(logrus.ErrorLevel) - cm := NewConnectionManager(logger) - - // Should not panic - cm.SetAllConnecting() - - if len(cm.status) != 0 { - t.Errorf("Expected 0 status entries, got %d", len(cm.status)) - } - }) -} - -func TestConnectionManager_GetHealthyConnections(t *testing.T) { - t.Run("returns only healthy connections", func(t *testing.T) { - logger := logrus.New() - logger.SetLevel(logrus.ErrorLevel) - cm := NewConnectionManager(logger) - - profile := &config.VaultProfile{ - Address: "http://localhost:8200", - AuthMethod: "token", - AuthConfig: config.AuthConfig{Token: "token"}, - } - - // Add multiple connections with different states - cm.AddConnection("healthy", createTestClient(t, profile)) - cm.AddConnection("sealed", createTestClient(t, profile)) - cm.AddConnection("not-initialized", createTestClient(t, profile)) - cm.AddConnection("disconnected", createTestClient(t, profile)) - - // Set statuses - cm.status["healthy"] = &ConnectionStatus{ - Connected: true, - Sealed: false, - Initialized: true, - } - cm.status["sealed"] = &ConnectionStatus{ - Connected: true, - Sealed: true, - Initialized: true, - } - cm.status["not-initialized"] = &ConnectionStatus{ - Connected: true, - Sealed: false, - Initialized: false, - } - cm.status["disconnected"] = &ConnectionStatus{ - Connected: false, - Sealed: false, - Initialized: true, - } - - healthy := cm.GetHealthyConnections() - - if len(healthy) != 1 { - t.Errorf("Expected 1 healthy connection, got %d", len(healthy)) - } - - if len(healthy) > 0 && healthy[0] != "healthy" { - t.Errorf("Expected 'healthy' to be in the list, got '%s'", healthy[0]) - } - }) - - t.Run("returns empty list when no healthy connections", func(t *testing.T) { - logger := logrus.New() - logger.SetLevel(logrus.ErrorLevel) - cm := NewConnectionManager(logger) - - profile := &config.VaultProfile{ - Address: "http://localhost:8200", - AuthMethod: "token", - AuthConfig: config.AuthConfig{Token: "token"}, - } - - cm.AddConnection("sealed", createTestClient(t, profile)) - cm.status["sealed"] = &ConnectionStatus{ - Connected: true, - Sealed: true, - Initialized: true, - } - - healthy := cm.GetHealthyConnections() - - if len(healthy) != 0 { - t.Errorf("Expected 0 healthy connections, got %d", len(healthy)) - } - }) - - t.Run("returns empty list when no connections", func(t *testing.T) { - logger := logrus.New() - logger.SetLevel(logrus.ErrorLevel) - cm := NewConnectionManager(logger) - - healthy := cm.GetHealthyConnections() - - if len(healthy) != 0 { - t.Errorf("Expected 0 healthy connections, got %d", len(healthy)) - } - }) -} - -func TestConnectionManager_GetConnectedConnections(t *testing.T) { - t.Run("returns all connected connections regardless of status", func(t *testing.T) { - logger := logrus.New() - logger.SetLevel(logrus.ErrorLevel) - cm := NewConnectionManager(logger) - - profile := &config.VaultProfile{ - Address: "http://localhost:8200", - AuthMethod: "token", - AuthConfig: config.AuthConfig{Token: "token"}, - } - - // Add multiple connections with different states - cm.AddConnection("connected-healthy", createTestClient(t, profile)) - cm.AddConnection("connected-sealed", createTestClient(t, profile)) - cm.AddConnection("connected-not-init", createTestClient(t, profile)) - cm.AddConnection("disconnected", createTestClient(t, profile)) - - // Set statuses - cm.status["connected-healthy"] = &ConnectionStatus{ - Connected: true, - Sealed: false, - Initialized: true, - } - cm.status["connected-sealed"] = &ConnectionStatus{ - Connected: true, - Sealed: true, - Initialized: true, - } - cm.status["connected-not-init"] = &ConnectionStatus{ - Connected: true, - Sealed: false, - Initialized: false, - } - cm.status["disconnected"] = &ConnectionStatus{ - Connected: false, - Sealed: false, - Initialized: true, - } - - connected := cm.GetConnectedConnections() - - if len(connected) != 3 { - t.Errorf("Expected 3 connected connections, got %d", len(connected)) - } - - connMap := make(map[string]bool) - for _, name := range connected { - connMap[name] = true - } - - if !connMap["connected-healthy"] { - t.Error("Expected 'connected-healthy' to be in the list") - } - if !connMap["connected-sealed"] { - t.Error("Expected 'connected-sealed' to be in the list") - } - if !connMap["connected-not-init"] { - t.Error("Expected 'connected-not-init' to be in the list") - } - if connMap["disconnected"] { - t.Error("Expected 'disconnected' to not be in the list") - } - }) - - t.Run("returns empty list when no connected connections", func(t *testing.T) { - logger := logrus.New() - logger.SetLevel(logrus.ErrorLevel) - cm := NewConnectionManager(logger) - - profile := &config.VaultProfile{ - Address: "http://localhost:8200", - AuthMethod: "token", - AuthConfig: config.AuthConfig{Token: "token"}, - } - - cm.AddConnection("disconnected", createTestClient(t, profile)) - cm.status["disconnected"] = &ConnectionStatus{ - Connected: false, - } - - connected := cm.GetConnectedConnections() - - if len(connected) != 0 { - t.Errorf("Expected 0 connected connections, got %d", len(connected)) - } - }) - - t.Run("returns empty list when no connections", func(t *testing.T) { - logger := logrus.New() - logger.SetLevel(logrus.ErrorLevel) - cm := NewConnectionManager(logger) - - connected := cm.GetConnectedConnections() - - if len(connected) != 0 { - t.Errorf("Expected 0 connected connections, got %d", len(connected)) - } - }) -} - -func TestConnectionManager_TestConnectionAsync(t *testing.T) { - t.Run("does nothing when connection not found", func(t *testing.T) { - logger := logrus.New() - logger.SetLevel(logrus.ErrorLevel) - cm := NewConnectionManager(logger) - - // Should not panic - cm.TestConnectionAsync("non-existent") - - // Give it a moment to potentially start goroutine - time.Sleep(10 * time.Millisecond) - }) - - t.Run("starts async connection test for existing connection", func(t *testing.T) { - logger := logrus.New() - logger.SetLevel(logrus.ErrorLevel) - cm := NewConnectionManager(logger) - - profile := &config.VaultProfile{ - Address: "http://localhost:8200", - AuthMethod: "token", - AuthConfig: config.AuthConfig{Token: "token"}, - } - client := createTestClient(t, profile) - - cm.AddConnection("test-vault", client) - - // Verify initial state - status, _ := cm.GetConnectionStatus("test-vault") - if !status.Connecting { - t.Error("Expected initial status to be connecting") - } - - // Call TestConnectionAsync - it will fail because vault is not running - cm.TestConnectionAsync("test-vault") - - // Wait for the goroutine to complete (with timeout) - connectionTimeout = 1 * time.Second - maxWait := 2 * time.Second - checkInterval := 100 * time.Millisecond - elapsed := time.Duration(0) - - for elapsed < maxWait { - time.Sleep(checkInterval) - elapsed += checkInterval - - status, _ = cm.GetConnectionStatus("test-vault") - if !status.Connecting { - // Status has been updated - break - } - } - - // Verify the status was updated (should be disconnected with error) - status, _ = cm.GetConnectionStatus("test-vault") - if status.Connecting { - t.Error("Expected status to not be connecting after test") - } - if status.Connected { - t.Error("Expected status to be disconnected (vault not running)") - } - if status.Error == "" { - t.Error("Expected error to be set (vault not running)") - } - }) -} - -func TestConnectionManager_RefreshConnectionStatus(t *testing.T) { - t.Run("returns error when connection not found", func(t *testing.T) { - logger := logrus.New() - logger.SetLevel(logrus.ErrorLevel) - cm := NewConnectionManager(logger) - - err := cm.RefreshConnectionStatus("non-existent") - if err == nil { - t.Error("Expected error when connection not found") - } - - expectedMsg := "connection 'non-existent' not found" - if err.Error() != expectedMsg { - t.Errorf("Expected error message '%s', got '%s'", expectedMsg, err.Error()) - } - }) - - t.Run("returns error when vault is not reachable", func(t *testing.T) { - logger := logrus.New() - logger.SetLevel(logrus.ErrorLevel) - cm := NewConnectionManager(logger) - - profile := &config.VaultProfile{ - Address: "http://localhost:8200", - AuthMethod: "token", - AuthConfig: config.AuthConfig{Token: "token"}, - } - client := createTestClient(t, profile) - - cm.AddConnection("test-vault", client) - - err := cm.RefreshConnectionStatus("test-vault") - if err == nil { - t.Error("Expected error when vault is not reachable") - } - - // Verify status was updated with error - status, _ := cm.GetConnectionStatus("test-vault") - if status.Connected { - t.Error("Expected status to be disconnected") - } - if status.Error == "" { - t.Error("Expected error to be set") - } - }) -} - -func TestConnectionManager_RefreshAllConnections(t *testing.T) { - t.Run("refreshes all connections", func(t *testing.T) { - logger := logrus.New() - logger.SetLevel(logrus.ErrorLevel) - cm := NewConnectionManager(logger) - - profile := &config.VaultProfile{ - Address: "http://localhost:8200", - AuthMethod: "token", - AuthConfig: config.AuthConfig{Token: "token"}, - } - - cm.AddConnection("vault1", createTestClient(t, profile)) - cm.AddConnection("vault2", createTestClient(t, profile)) - cm.AddConnection("vault3", createTestClient(t, profile)) - - // Set some initial states - cm.status["vault1"].Connected = true - cm.status["vault2"].Connected = true - cm.status["vault3"].Connected = false - cm.status["vault3"].Connecting = false - - // Call RefreshAllConnections - cm.RefreshAllConnections() - - // Wait for goroutines to complete (with timeout) - connectionTimeout = 1 * time.Second - maxWait := 2 * time.Second - checkInterval := 100 * time.Millisecond - elapsed := time.Duration(0) - - for elapsed < maxWait { - time.Sleep(checkInterval) - elapsed += checkInterval - - status1, _ := cm.GetConnectionStatus("vault1") - status2, _ := cm.GetConnectionStatus("vault2") - - // Check if both connections have finished their async tests - if !status1.Connecting && !status2.Connecting { - break - } - } - - // vault1 and vault2 should be refreshed (they were connected) - // vault3 should not be refreshed (it was disconnected and not connecting) - // Since vault is not running, they should all end up disconnected - status1, _ := cm.GetConnectionStatus("vault1") - status2, _ := cm.GetConnectionStatus("vault2") - - if status1.Connecting { - t.Error("Expected vault1 to not be connecting after refresh") - } - if status2.Connecting { - t.Error("Expected vault2 to not be connecting after refresh") - } - - // Verify errors are set - if status1.Error == "" { - t.Error("Expected vault1 to have an error (vault not running)") - } - if status2.Error == "" { - t.Error("Expected vault2 to have an error (vault not running)") - } - }) - - t.Run("does nothing when no connections", func(t *testing.T) { - logger := logrus.New() - logger.SetLevel(logrus.ErrorLevel) - cm := NewConnectionManager(logger) - - // Should not panic - cm.RefreshAllConnections() - - time.Sleep(50 * time.Millisecond) - }) - - t.Run("skips failed connections", func(t *testing.T) { - logger := logrus.New() - logger.SetLevel(logrus.ErrorLevel) - cm := NewConnectionManager(logger) - - profile := &config.VaultProfile{ - Address: "http://localhost:8200", - AuthMethod: "token", - AuthConfig: config.AuthConfig{Token: "token"}, - } - - cm.AddConnection("failed-vault", createTestClient(t, profile)) - - // Set to failed state (not connected, not connecting) - cm.status["failed-vault"].Connected = false - cm.status["failed-vault"].Connecting = false - cm.status["failed-vault"].Error = "previous error" - - initialError := cm.status["failed-vault"].Error - - cm.RefreshAllConnections() - - time.Sleep(50 * time.Millisecond) - - // Error should remain the same (connection was not refreshed) - status, _ := cm.GetConnectionStatus("failed-vault") - if status.Error != initialError { - t.Error("Expected error to remain unchanged for failed connection") - } - }) -} - -func TestConnectionStatus(t *testing.T) { - t.Run("can create connection status with all fields", func(t *testing.T) { - now := time.Now() - status := &ConnectionStatus{ - Connecting: true, - Connected: false, - Address: "http://localhost:8200", - Sealed: true, - Initialized: false, - Version: "1.0.0", - ClusterID: "cluster-123", - LastCheck: now, - Error: "test error", - } - - if !status.Connecting { - t.Error("Expected Connecting to be true") - } - if status.Connected { - t.Error("Expected Connected to be false") - } - if status.Address != "http://localhost:8200" { - t.Errorf("Expected Address 'http://localhost:8200', got '%s'", status.Address) - } - if !status.Sealed { - t.Error("Expected Sealed to be true") - } - if status.Initialized { - t.Error("Expected Initialized to be false") - } - if status.Version != "1.0.0" { - t.Errorf("Expected Version '1.0.0', got '%s'", status.Version) - } - if status.ClusterID != "cluster-123" { - t.Errorf("Expected ClusterID 'cluster-123', got '%s'", status.ClusterID) - } - if !status.LastCheck.Equal(now) { - t.Error("Expected LastCheck to match") - } - if status.Error != "test error" { - t.Errorf("Expected Error 'test error', got '%s'", status.Error) - } - }) -} - -func TestConnectionManager_ConcurrentAccess(t *testing.T) { - t.Run("handles concurrent reads and writes safely", func(t *testing.T) { - logger := logrus.New() - logger.SetLevel(logrus.ErrorLevel) - cm := NewConnectionManager(logger) - - profile := &config.VaultProfile{ - Address: "http://localhost:8200", - AuthMethod: "token", - AuthConfig: config.AuthConfig{Token: "token"}, - } - - // Add initial connection - cm.AddConnection("test-vault", createTestClient(t, profile)) - - // Spawn multiple goroutines that access the connection manager - done := make(chan bool, 10) - - // Readers - for i := 0; i < 5; i++ { - go func() { - for j := 0; j < 10; j++ { - cm.GetConnection("test-vault") - cm.GetConnectionStatus("test-vault") - cm.ListConnections() - cm.GetHealthyConnections() - cm.GetConnectedConnections() - time.Sleep(time.Millisecond) - } - done <- true - }() - } - - // Writers - for i := 0; i < 5; i++ { - go func(id int) { - for j := 0; j < 10; j++ { - name := "vault-" + string(rune('0'+id)) - cm.AddConnection(name, createTestClient(t, profile)) - cm.SetAllConnecting() - cm.RemoveConnection(name) - time.Sleep(time.Millisecond) - } - done <- true - }(i) - } - - // Wait for all goroutines to complete - for i := 0; i < 10; i++ { - <-done - } - }) -} - -// Helper function for creating test API client (used across tests) -func createTestAPIClient(address string) (*api.Client, error) { - config := api.DefaultConfig() - config.Address = address - return api.NewClient(config) -} diff --git a/internal/vault/dependencies.go b/internal/vault/dependencies.go deleted file mode 100644 index 4e0789f..0000000 --- a/internal/vault/dependencies.go +++ /dev/null @@ -1,37 +0,0 @@ -package vault - -import ( - "github.com/hashicorp/vault/api" - "github.com/rvolykh/vui/internal/config" -) - -type vaultAuthenticator interface { - VerifyAuthentication(client *api.Client) error - Authenticate(client *api.Client, profile *config.VaultProfile) error -} - -type vaultSecretsManager interface { - ListSecrets(path string) ([]*SecretNode, error) - GetSecret(path string) (*SecretNode, error) - CreateSecret(path string, data map[string]interface{}) error - UpdateSecret(path string, data map[string]interface{}) error - DeleteSecret(path string) error - BuildTree(rootPath string, maxDepth int) (*SecretNode, error) - SearchSecrets(pattern string, rootPath string) ([]*SecretNode, error) - SearchSecretsByValue(valuePattern string, rootPath string) ([]*SearchResult, error) - SearchSecretsByKey(keyPattern string, rootPath string) ([]*SearchResult, error) -} - -type vaultConnectionManager interface { - AddConnection(name string, client *Client) - TestConnectionAsync(name string) - RemoveConnection(name string) - GetConnection(name string) (*Client, error) - GetConnectionStatus(name string) (*ConnectionStatus, error) - ListConnections() []string - RefreshConnectionStatus(name string) error - RefreshAllConnections() - SetAllConnecting() - GetHealthyConnections() []string - GetConnectedConnections() []string -} diff --git a/internal/vault/dependencies_test.go b/internal/vault/dependencies_test.go deleted file mode 100644 index 316ce19..0000000 --- a/internal/vault/dependencies_test.go +++ /dev/null @@ -1,485 +0,0 @@ -package vault - -import ( - "fmt" - - "github.com/hashicorp/vault/api" - "github.com/rvolykh/vui/internal/config" -) - -// MockVaultAuthenticator is a mock implementation of vaultAuthenticator -type MockVaultAuthenticator struct { - VerifyAuthenticationFunc func(client *api.Client) error - AuthenticateFunc func(client *api.Client, profile *config.VaultProfile) error - - // Call tracking - VerifyAuthenticationCalls int - AuthenticateCalls int - LastClient *api.Client - LastProfile *config.VaultProfile -} - -func NewMockVaultAuthenticator() *MockVaultAuthenticator { - return &MockVaultAuthenticator{} -} - -func (m *MockVaultAuthenticator) VerifyAuthentication(client *api.Client) error { - m.VerifyAuthenticationCalls++ - m.LastClient = client - - if m.VerifyAuthenticationFunc != nil { - return m.VerifyAuthenticationFunc(client) - } - return nil -} - -func (m *MockVaultAuthenticator) Authenticate(client *api.Client, profile *config.VaultProfile) error { - m.AuthenticateCalls++ - m.LastClient = client - m.LastProfile = profile - - if m.AuthenticateFunc != nil { - return m.AuthenticateFunc(client, profile) - } - return nil -} - -// Reset resets the call tracking -func (m *MockVaultAuthenticator) Reset() { - m.VerifyAuthenticationCalls = 0 - m.AuthenticateCalls = 0 - m.LastClient = nil - m.LastProfile = nil -} - -// MockVaultSecretsManager is a mock implementation of vaultSecretsManager -type MockVaultSecretsManager struct { - ListSecretsFunc func(path string) ([]*SecretNode, error) - GetSecretFunc func(path string) (*SecretNode, error) - CreateSecretFunc func(path string, data map[string]interface{}) error - UpdateSecretFunc func(path string, data map[string]interface{}) error - DeleteSecretFunc func(path string) error - BuildTreeFunc func(rootPath string, maxDepth int) (*SecretNode, error) - SearchSecretsFunc func(pattern string, rootPath string) ([]*SecretNode, error) - SearchSecretsByValueFunc func(valuePattern string, rootPath string) ([]*SearchResult, error) - SearchSecretsByKeyFunc func(keyPattern string, rootPath string) ([]*SearchResult, error) - - // Call tracking - ListSecretsCalls int - GetSecretCalls int - CreateSecretCalls int - UpdateSecretCalls int - DeleteSecretCalls int - BuildTreeCalls int - SearchSecretsCalls int - SearchSecretsByValueCalls int - SearchSecretsByKeyCalls int - - // Last call parameters - LastPath string - LastData map[string]interface{} - LastRootPath string - LastMaxDepth int - LastPattern string - LastValuePattern string - LastKeyPattern string -} - -func NewMockVaultSecretsManager() *MockVaultSecretsManager { - return &MockVaultSecretsManager{} -} - -func (m *MockVaultSecretsManager) ListSecrets(path string) ([]*SecretNode, error) { - m.ListSecretsCalls++ - m.LastPath = path - - if m.ListSecretsFunc != nil { - return m.ListSecretsFunc(path) - } - return []*SecretNode{}, nil -} - -func (m *MockVaultSecretsManager) GetSecret(path string) (*SecretNode, error) { - m.GetSecretCalls++ - m.LastPath = path - - if m.GetSecretFunc != nil { - return m.GetSecretFunc(path) - } - return &SecretNode{ - Name: "test-secret", - Path: path, - IsSecret: true, - Data: map[string]interface{}{}, - }, nil -} - -func (m *MockVaultSecretsManager) CreateSecret(path string, data map[string]interface{}) error { - m.CreateSecretCalls++ - m.LastPath = path - m.LastData = data - - if m.CreateSecretFunc != nil { - return m.CreateSecretFunc(path, data) - } - return nil -} - -func (m *MockVaultSecretsManager) UpdateSecret(path string, data map[string]interface{}) error { - m.UpdateSecretCalls++ - m.LastPath = path - m.LastData = data - - if m.UpdateSecretFunc != nil { - return m.UpdateSecretFunc(path, data) - } - return nil -} - -func (m *MockVaultSecretsManager) DeleteSecret(path string) error { - m.DeleteSecretCalls++ - m.LastPath = path - - if m.DeleteSecretFunc != nil { - return m.DeleteSecretFunc(path) - } - return nil -} - -func (m *MockVaultSecretsManager) BuildTree(rootPath string, maxDepth int) (*SecretNode, error) { - m.BuildTreeCalls++ - m.LastRootPath = rootPath - m.LastMaxDepth = maxDepth - - if m.BuildTreeFunc != nil { - return m.BuildTreeFunc(rootPath, maxDepth) - } - return &SecretNode{ - Name: "root", - Path: rootPath, - IsSecret: false, - Children: []*SecretNode{}, - }, nil -} - -func (m *MockVaultSecretsManager) SearchSecrets(pattern string, rootPath string) ([]*SecretNode, error) { - m.SearchSecretsCalls++ - m.LastPattern = pattern - m.LastRootPath = rootPath - - if m.SearchSecretsFunc != nil { - return m.SearchSecretsFunc(pattern, rootPath) - } - return []*SecretNode{}, nil -} - -func (m *MockVaultSecretsManager) SearchSecretsByValue(valuePattern string, rootPath string) ([]*SearchResult, error) { - m.SearchSecretsByValueCalls++ - m.LastValuePattern = valuePattern - m.LastRootPath = rootPath - - if m.SearchSecretsByValueFunc != nil { - return m.SearchSecretsByValueFunc(valuePattern, rootPath) - } - return []*SearchResult{}, nil -} - -func (m *MockVaultSecretsManager) SearchSecretsByKey(keyPattern string, rootPath string) ([]*SearchResult, error) { - m.SearchSecretsByKeyCalls++ - m.LastKeyPattern = keyPattern - m.LastRootPath = rootPath - - if m.SearchSecretsByKeyFunc != nil { - return m.SearchSecretsByKeyFunc(keyPattern, rootPath) - } - return []*SearchResult{}, nil -} - -// Reset resets the call tracking -func (m *MockVaultSecretsManager) Reset() { - m.ListSecretsCalls = 0 - m.GetSecretCalls = 0 - m.CreateSecretCalls = 0 - m.UpdateSecretCalls = 0 - m.DeleteSecretCalls = 0 - m.BuildTreeCalls = 0 - m.SearchSecretsCalls = 0 - m.SearchSecretsByValueCalls = 0 - m.SearchSecretsByKeyCalls = 0 - - m.LastPath = "" - m.LastData = nil - m.LastRootPath = "" - m.LastMaxDepth = 0 - m.LastPattern = "" - m.LastValuePattern = "" - m.LastKeyPattern = "" -} - -// MockVaultConnectionManager is a mock implementation of vaultConnectionManager -type MockVaultConnectionManager struct { - AddConnectionFunc func(name string, client *Client) - TestConnectionAsyncFunc func(name string) - RemoveConnectionFunc func(name string) - GetConnectionFunc func(name string) (*Client, error) - GetConnectionStatusFunc func(name string) (*ConnectionStatus, error) - ListConnectionsFunc func() []string - RefreshConnectionStatusFunc func(name string) error - RefreshAllConnectionsFunc func() - SetAllConnectingFunc func() - GetHealthyConnectionsFunc func() []string - GetConnectedConnectionsFunc func() []string - - // Call tracking - AddConnectionCalls int - TestConnectionAsyncCalls int - RemoveConnectionCalls int - GetConnectionCalls int - GetConnectionStatusCalls int - ListConnectionsCalls int - RefreshConnectionStatusCalls int - RefreshAllConnectionsCalls int - SetAllConnectingCalls int - GetHealthyConnectionsCalls int - GetConnectedConnectionsCalls int - - // Last call parameters - LastName string - LastClient *Client - - // Mock data - Connections map[string]*Client - ConnectionStatuses map[string]*ConnectionStatus -} - -func NewMockVaultConnectionManager() *MockVaultConnectionManager { - return &MockVaultConnectionManager{ - Connections: make(map[string]*Client), - ConnectionStatuses: make(map[string]*ConnectionStatus), - } -} - -func (m *MockVaultConnectionManager) AddConnection(name string, client *Client) { - m.AddConnectionCalls++ - m.LastName = name - m.LastClient = client - - if m.AddConnectionFunc != nil { - m.AddConnectionFunc(name, client) - return - } - - m.Connections[name] = client -} - -func (m *MockVaultConnectionManager) TestConnectionAsync(name string) { - m.TestConnectionAsyncCalls++ - m.LastName = name - - if m.TestConnectionAsyncFunc != nil { - m.TestConnectionAsyncFunc(name) - } -} - -func (m *MockVaultConnectionManager) RemoveConnection(name string) { - m.RemoveConnectionCalls++ - m.LastName = name - - if m.RemoveConnectionFunc != nil { - m.RemoveConnectionFunc(name) - return - } - - delete(m.Connections, name) - delete(m.ConnectionStatuses, name) -} - -func (m *MockVaultConnectionManager) GetConnection(name string) (*Client, error) { - m.GetConnectionCalls++ - m.LastName = name - - if m.GetConnectionFunc != nil { - return m.GetConnectionFunc(name) - } - - if client, ok := m.Connections[name]; ok { - return client, nil - } - return nil, fmt.Errorf("connection '%s' not found", name) -} - -func (m *MockVaultConnectionManager) GetConnectionStatus(name string) (*ConnectionStatus, error) { - m.GetConnectionStatusCalls++ - m.LastName = name - - if m.GetConnectionStatusFunc != nil { - return m.GetConnectionStatusFunc(name) - } - - if status, ok := m.ConnectionStatuses[name]; ok { - return status, nil - } - return &ConnectionStatus{ - Address: "http://localhost:8200", - Connected: false, - Connecting: false, - }, nil -} - -func (m *MockVaultConnectionManager) ListConnections() []string { - m.ListConnectionsCalls++ - - if m.ListConnectionsFunc != nil { - return m.ListConnectionsFunc() - } - - names := make([]string, 0, len(m.Connections)) - for name := range m.Connections { - names = append(names, name) - } - return names -} - -func (m *MockVaultConnectionManager) RefreshConnectionStatus(name string) error { - m.RefreshConnectionStatusCalls++ - m.LastName = name - - if m.RefreshConnectionStatusFunc != nil { - return m.RefreshConnectionStatusFunc(name) - } - return nil -} - -func (m *MockVaultConnectionManager) RefreshAllConnections() { - m.RefreshAllConnectionsCalls++ - - if m.RefreshAllConnectionsFunc != nil { - m.RefreshAllConnectionsFunc() - } -} - -func (m *MockVaultConnectionManager) SetAllConnecting() { - m.SetAllConnectingCalls++ - - if m.SetAllConnectingFunc != nil { - m.SetAllConnectingFunc() - return - } - - for name := range m.ConnectionStatuses { - if m.ConnectionStatuses[name] != nil { - m.ConnectionStatuses[name].Connecting = true - m.ConnectionStatuses[name].Connected = false - } - } -} - -func (m *MockVaultConnectionManager) GetHealthyConnections() []string { - m.GetHealthyConnectionsCalls++ - - if m.GetHealthyConnectionsFunc != nil { - return m.GetHealthyConnectionsFunc() - } - - healthy := make([]string, 0) - for name, status := range m.ConnectionStatuses { - if status != nil && status.Connected && !status.Sealed { - healthy = append(healthy, name) - } - } - return healthy -} - -func (m *MockVaultConnectionManager) GetConnectedConnections() []string { - m.GetConnectedConnectionsCalls++ - - if m.GetConnectedConnectionsFunc != nil { - return m.GetConnectedConnectionsFunc() - } - - connected := make([]string, 0) - for name, status := range m.ConnectionStatuses { - if status != nil && status.Connected { - connected = append(connected, name) - } - } - return connected -} - -// Reset resets the call tracking -func (m *MockVaultConnectionManager) Reset() { - m.AddConnectionCalls = 0 - m.TestConnectionAsyncCalls = 0 - m.RemoveConnectionCalls = 0 - m.GetConnectionCalls = 0 - m.GetConnectionStatusCalls = 0 - m.ListConnectionsCalls = 0 - m.RefreshConnectionStatusCalls = 0 - m.RefreshAllConnectionsCalls = 0 - m.SetAllConnectingCalls = 0 - m.GetHealthyConnectionsCalls = 0 - m.GetConnectedConnectionsCalls = 0 - - m.LastName = "" - m.LastClient = nil -} - -// Helper methods for setting up mock data - -func (m *MockVaultConnectionManager) SetupConnection(name string, connected bool, sealed bool) { - m.Connections[name] = &Client{} - m.ConnectionStatuses[name] = &ConnectionStatus{ - Address: fmt.Sprintf("http://%s.vault.local:8200", name), - Connected: connected, - Connecting: false, - Sealed: sealed, - Error: "", - } -} - -func (m *MockVaultConnectionManager) SetupConnectionWithError(name string, errMsg string) { - m.Connections[name] = &Client{} - m.ConnectionStatuses[name] = &ConnectionStatus{ - Address: fmt.Sprintf("http://%s.vault.local:8200", name), - Connected: false, - Connecting: false, - Sealed: false, - Error: errMsg, - } -} - -// MockDialogService is a mock implementation for dialog services -type MockDialogService struct { - ShowInfoCalls int - ShowErrorCalls int - LastTitle string - LastMessage string -} - -func NewMockDialogService() *MockDialogService { - return &MockDialogService{} -} - -func (m *MockDialogService) ShowInfo(title, message string, callback func()) { - m.ShowInfoCalls++ - m.LastTitle = title - m.LastMessage = message - if callback != nil { - callback() - } -} - -func (m *MockDialogService) ShowError(message string, callback func()) { - m.ShowErrorCalls++ - m.LastMessage = message - if callback != nil { - callback() - } -} - -func (m *MockDialogService) Reset() { - m.ShowInfoCalls = 0 - m.ShowErrorCalls = 0 - m.LastTitle = "" - m.LastMessage = "" -} diff --git a/internal/vault/manager.go b/internal/vault/manager.go deleted file mode 100644 index a378674..0000000 --- a/internal/vault/manager.go +++ /dev/null @@ -1,445 +0,0 @@ -package vault - -import ( - "fmt" - "sync" - - "github.com/hashicorp/vault/api" - "github.com/rvolykh/vui/internal/config" - "github.com/rvolykh/vui/internal/vault/auth" - "github.com/sirupsen/logrus" -) - -// Manager manages multiple Vault connections -type Manager struct { - config *config.Config - clients map[string]*Client - activeVault string - connectionMgr vaultConnectionManager - secretsMgr vaultSecretsManager - authMgr vaultAuthenticator - mutex sync.RWMutex - logger *logrus.Logger -} - -// NewManager creates a new vault manager -func NewManager(cfg *config.Config, logger *logrus.Logger) (*Manager, error) { - if logger == nil { - logger = logrus.New() - logger.SetLevel(logrus.InfoLevel) - } - - manager := &Manager{ - config: cfg, - clients: make(map[string]*Client), - connectionMgr: NewConnectionManager(logger), - authMgr: auth.NewAuthManager(logger), - logger: logger, - } - - // Initialize all configured vault clients - if err := manager.initializeAllClients(); err != nil { - return nil, fmt.Errorf("failed to initialize vault clients: %w", err) - } - // Test connections asynchronously - manager.testAllConnectionsAsync() - - return manager, nil -} - -// initializeAllClients initializes all configured vault clients -func (m *Manager) initializeAllClients() error { - for name, profile := range m.config.Vaults { - p := profile - if err := m.initializeProfileClient(name, &p); err != nil { - m.logger.Warnf("Failed to initialize client for profile '%s': %v", name, err) - continue - } - } - // Initialize secrets manager for the active client - if m.activeVault != "" { - if client, exists := m.clients[m.activeVault]; exists { - m.secretsMgr = NewSecretsManager(client, m.logger) - } else { - m.logger.Warnf("Active vault '%s' not found in profiles, no secrets manager initialized", m.activeVault) - } - } else if len(m.clients) > 0 { - // if no active vault is set, use the first one - for name, client := range m.clients { - m.activeVault = name - m.secretsMgr = NewSecretsManager(client, m.logger) - m.logger.Infof("No default vault set, using '%s' as active vault", name) - break - } - } - - return nil -} - -// testAllConnectionsAsync tests all vault connections asynchronously -func (m *Manager) testAllConnectionsAsync() { - for name := range m.clients { - m.connectionMgr.TestConnectionAsync(name) - } -} - -// initializeProfileClient creates a client from a vault profile -func (m *Manager) initializeProfileClient(name string, profile *config.VaultProfile) error { - client, err := m.createClient(profile) - if err != nil { - return err - } - - // Don't authenticate during initialization - authentication will happen when user selects a profile - // This prevents unnecessary connection attempts at startup for all configured vaults - - m.mutex.Lock() - m.clients[name] = client - m.mutex.Unlock() - - // Add to connection manager - m.connectionMgr.AddConnection(name, client) - - return nil -} - -// initializeProfileClientLocked creates a client from a vault profile (assumes caller holds mutex) -func (m *Manager) initializeProfileClientLocked(name string, profile *config.VaultProfile) error { - client, err := m.createClient(profile) - if err != nil { - return err - } - - // Don't authenticate during initialization - authentication will happen when user selects a profile - // This prevents unnecessary connection attempts at startup for all configured vaults - - // Caller already holds m.mutex - m.clients[name] = client - - // Add to connection manager - m.connectionMgr.AddConnection(name, client) - - return nil -} - -// createClient creates a new vault client -func (m *Manager) createClient(profile *config.VaultProfile) (*Client, error) { - // Create Vault API client - apiConfig := api.DefaultConfig() - apiConfig.Address = profile.Address - - if profile.CertPath != "" { - if err := apiConfig.ConfigureTLS(&api.TLSConfig{ - CACert: profile.CertPath, - }); err != nil { - return nil, fmt.Errorf("failed to configure TLS: %w", err) - } - } - - apiClient, err := api.NewClient(apiConfig) - if err != nil { - return nil, fmt.Errorf("failed to create vault client: %w", err) - } - - // Set namespace if provided - if profile.Namespace != "" { - apiClient.SetNamespace(profile.Namespace) - } - - client := &Client{ - apiClient: apiClient, - profile: profile, - logger: m.logger, - } - - return client, nil -} - -// GetActiveClient returns the currently active vault client -func (m *Manager) GetActiveClient() (*Client, error) { - m.mutex.RLock() - defer m.mutex.RUnlock() - - client, exists := m.clients[m.activeVault] - if !exists { - return nil, fmt.Errorf("active vault client '%s' not found", m.activeVault) - } - - return client, nil -} - -// GetClient returns a specific vault client by name -func (m *Manager) GetClient(name string) (*Client, error) { - m.mutex.RLock() - defer m.mutex.RUnlock() - - client, exists := m.clients[name] - if !exists { - return nil, fmt.Errorf("vault client '%s' not found", name) - } - - return client, nil -} - -// SwitchVault switches to a different vault -func (m *Manager) SwitchVault(name string) error { - m.mutex.Lock() - defer m.mutex.Unlock() - - client, exists := m.clients[name] - if !exists { - return fmt.Errorf("vault '%s' not found", name) - } - - // Get the profile for authentication - profile, ok := m.config.Vaults[name] - if !ok { - return fmt.Errorf("profile for vault '%s' not found", name) - } - - // Authenticate the client when switching (if not already authenticated) - if err := m.authMgr.Authenticate(client.apiClient, &profile); err != nil { - m.logger.Errorf("Failed to authenticate to vault '%s': %v", name, err) - return fmt.Errorf("authentication failed: %w", err) - } - - // Verify that authentication actually succeeded - if err := m.authMgr.VerifyAuthentication(client.apiClient); err != nil { - m.logger.Errorf("Authentication verification failed for vault '%s': %v", name, err) - return fmt.Errorf("authentication verification failed: %w", err) - } - - m.activeVault = name - - // Update secrets manager for the new active vault - m.secretsMgr = NewSecretsManager(client, m.logger) - - m.logger.Infof("Switched to vault: %s", name) - return nil -} - -// AddVault adds a new vault connection -func (m *Manager) AddVault(name string, profile *config.VaultProfile) error { - m.mutex.Lock() - defer m.mutex.Unlock() - - if _, exists := m.config.Vaults[name]; exists { - return fmt.Errorf("vault profile '%s' already exists", name) - } - - client, err := m.createClient(profile) - if err != nil { - return fmt.Errorf("failed to add vault '%s': %w", name, err) - } - - m.config.Vaults[name] = *profile - m.clients[name] = client - - if err := m.config.Save(); err != nil { - // rollback - delete(m.config.Vaults, name) - delete(m.clients, name) - return fmt.Errorf("failed to save config: %w", err) - } - m.connectionMgr.AddConnection(name, client) - m.connectionMgr.TestConnectionAsync(name) - - m.logger.Infof("Added vault: %s", name) - return nil -} - -// ListVaults returns a list of available vault connections -func (m *Manager) ListVaults() []string { - m.mutex.RLock() - defer m.mutex.RUnlock() - - vaults := make([]string, 0, len(m.clients)) - for name := range m.clients { - vaults = append(vaults, name) - } - - return vaults -} - -// GetActiveVault returns the name of the currently active vault -func (m *Manager) GetActiveVault() string { - m.mutex.RLock() - defer m.mutex.RUnlock() - return m.activeVault -} - -// GetConnectionManager returns the connection manager -func (m *Manager) GetConnectionManager() vaultConnectionManager { - return m.connectionMgr -} - -// GetSecretsManager returns the secrets manager for the active vault -func (m *Manager) GetSecretsManager() (*SecretsManager, error) { - client, err := m.GetActiveClient() - if err != nil { - return nil, err - } - return NewSecretsManager(client, m.logger), nil -} - -// RefreshConnections refreshes all vault connections -func (m *Manager) RefreshConnections() { - m.connectionMgr.RefreshAllConnections() -} - -// GetConnectionStatus returns the status of a specific connection -func (m *Manager) GetConnectionStatus(name string) (*ConnectionStatus, error) { - return m.connectionMgr.GetConnectionStatus(name) -} - -// GetHealthyConnections returns a list of healthy connections -func (m *Manager) GetHealthyConnections() []string { - return m.connectionMgr.GetHealthyConnections() -} - -// GetConnectedConnections returns a list of all connected vaults (regardless of sealed/initialized status) -func (m *Manager) GetConnectedConnections() []string { - return m.connectionMgr.GetConnectedConnections() -} - -// GetVaultProfiles returns all vault profiles -func (m *Manager) GetVaultProfiles() map[string]config.VaultProfile { - return m.config.Vaults -} - -// GetVaultProfile returns a specific vault profile -func (m *Manager) GetVaultProfile(name string) (*config.VaultProfile, error) { - profile, exists := m.config.Vaults[name] - if !exists { - return nil, fmt.Errorf("vault profile '%s' not found", name) - } - return &profile, nil -} - -// GetVaultStatus returns detailed status information for all vaults -func (m *Manager) GetVaultStatus() map[string]*VaultStatus { - status := make(map[string]*VaultStatus) - - m.mutex.RLock() - defer m.mutex.RUnlock() - - for name, client := range m.clients { - connStatus, err := m.connectionMgr.GetConnectionStatus(name) - if err != nil { - connStatus = &ConnectionStatus{ - Connected: false, - Error: err.Error(), - } - } - - vaultStatus := &VaultStatus{ - Name: name, - Address: client.profile.Address, - Namespace: client.profile.Namespace, - AuthMethod: client.profile.AuthMethod, - Connected: connStatus.Connected, - Sealed: connStatus.Sealed, - Error: connStatus.Error, - Active: name == m.activeVault, - } - - status[name] = vaultStatus - } - - return status -} - -// VaultStatus represents the status of a vault connection -type VaultStatus struct { - Name string `json:"name"` - Address string `json:"address"` - Namespace string `json:"namespace"` - AuthMethod string `json:"auth_method"` - Connected bool `json:"connected"` - Sealed bool `json:"sealed"` - Error string `json:"error,omitempty"` - Active bool `json:"active"` -} - -// ReloadConfiguration reloads the configuration from disk and reinitializes vault profiles -func (m *Manager) ReloadConfiguration() error { - m.logger.Info("Reloading configuration from disk...") - - // Reload config from disk - newConfig, err := config.Load() - if err != nil { - return fmt.Errorf("failed to reload configuration: %w", err) - } - - m.mutex.Lock() - defer m.mutex.Unlock() - - // Track which profiles exist in the new config - newProfiles := make(map[string]bool) - for name := range newConfig.Vaults { - newProfiles[name] = true - } - - // Remove profiles that no longer exist in the config - for name := range m.clients { - if !newProfiles[name] { - m.logger.Infof("Removing vault profile '%s' (no longer in config)", name) - m.connectionMgr.RemoveConnection(name) - delete(m.clients, name) - } - } - - // Add new profiles or update existing ones - for name, profile := range newConfig.Vaults { - p := profile - _, exists := m.clients[name] - - if !exists { - // New profile - add it - m.logger.Infof("Adding new vault profile '%s'", name) - if err := m.initializeProfileClientLocked(name, &p); err != nil { - m.logger.Warnf("Failed to initialize new profile '%s': %v", name, err) - continue - } - } else { - // Existing profile - check if it changed - oldProfile := m.config.Vaults[name] - if profileChanged(oldProfile, p) { - m.logger.Infof("Updating vault profile '%s'", name) - // Remove old connection - m.connectionMgr.RemoveConnection(name) - delete(m.clients, name) - // Reinitialize with new profile - if err := m.initializeProfileClientLocked(name, &p); err != nil { - m.logger.Warnf("Failed to reinitialize profile '%s': %v", name, err) - continue - } - } - } - } - - // Update the config reference - m.config = newConfig - - // Re-test all connections - m.logger.Info("Re-testing all vault connections...") - for name := range m.clients { - m.connectionMgr.TestConnectionAsync(name) - } - - m.logger.Info("Configuration reloaded successfully") - return nil -} - -// profileChanged checks if a vault profile has changed -func profileChanged(old, new config.VaultProfile) bool { - return old.Address != new.Address || - old.AuthMethod != new.AuthMethod || - old.Namespace != new.Namespace || - !authConfigEqual(old.AuthConfig, new.AuthConfig) -} - -// authConfigEqual checks if two auth configs are equal -func authConfigEqual(a, b config.AuthConfig) bool { - return a == b -} diff --git a/internal/vault/manager_test.go b/internal/vault/manager_test.go deleted file mode 100644 index d9b1557..0000000 --- a/internal/vault/manager_test.go +++ /dev/null @@ -1,858 +0,0 @@ -package vault - -import ( - "fmt" - "testing" - - "github.com/hashicorp/vault/api" - "github.com/rvolykh/vui/internal/config" - "github.com/sirupsen/logrus" -) - -// Helper function to create a test manager with mocks -func createTestManager(t *testing.T) (*Manager, *MockVaultConnectionManager, *MockVaultAuthenticator, *MockVaultSecretsManager) { - t.Helper() - - logger := logrus.New() - logger.SetLevel(logrus.ErrorLevel) // Reduce noise in tests - - cfg := &config.Config{ - Vaults: map[string]config.VaultProfile{ - "vault1": { - Address: "http://vault1.local:8200", - AuthMethod: "token", - Namespace: "ns1", - AuthConfig: config.AuthConfig{ - Token: "token1", - }, - }, - "vault2": { - Address: "http://vault2.local:8200", - AuthMethod: "userpass", - Namespace: "ns2", - AuthConfig: config.AuthConfig{ - Username: "user", - Password: "pass", - }, - }, - }, - } - - mockConnMgr := NewMockVaultConnectionManager() - mockAuthMgr := NewMockVaultAuthenticator() - mockSecretsMgr := NewMockVaultSecretsManager() - - manager := &Manager{ - config: cfg, - clients: make(map[string]*Client), - connectionMgr: mockConnMgr, - authMgr: mockAuthMgr, - secretsMgr: mockSecretsMgr, - logger: logger, - } - - return manager, mockConnMgr, mockAuthMgr, mockSecretsMgr -} - -// Helper function to create a test client -func createTestClient(t *testing.T, profile *config.VaultProfile) *Client { - t.Helper() - - apiConfig := api.DefaultConfig() - apiConfig.Address = profile.Address - - apiClient, err := api.NewClient(apiConfig) - if err != nil { - t.Fatalf("Failed to create test client: %v", err) - } - - if profile.Namespace != "" { - apiClient.SetNamespace(profile.Namespace) - } - - return &Client{ - apiClient: apiClient, - profile: profile, - logger: logrus.New(), - } -} - -func TestNewManager(t *testing.T) { - t.Run("creates manager with valid config", func(t *testing.T) { - logger := logrus.New() - cfg := &config.Config{ - Vaults: map[string]config.VaultProfile{ - "test-vault": { - Address: "http://localhost:8200", - AuthMethod: "token", - AuthConfig: config.AuthConfig{ - Token: "test-token", - }, - }, - }, - } - - manager, err := NewManager(cfg, logger) - if err != nil { - t.Fatalf("Expected no error, got: %v", err) - } - - if manager == nil { - t.Fatal("Expected manager to be created") - } - - if len(manager.clients) != 1 { - t.Errorf("Expected 1 client, got %d", len(manager.clients)) - } - }) - - t.Run("creates manager with nil logger", func(t *testing.T) { - cfg := &config.Config{ - Vaults: map[string]config.VaultProfile{ - "test-vault": { - Address: "http://localhost:8200", - AuthMethod: "token", - AuthConfig: config.AuthConfig{ - Token: "test-token", - }, - }, - }, - } - - manager, err := NewManager(cfg, nil) - if err != nil { - t.Fatalf("Expected no error, got: %v", err) - } - - if manager == nil { - t.Fatal("Expected manager to be created") - } - - if manager.logger == nil { - t.Error("Expected logger to be created when nil is passed") - } - }) - - t.Run("creates manager with empty vaults", func(t *testing.T) { - logger := logrus.New() - cfg := &config.Config{ - Vaults: map[string]config.VaultProfile{}, - } - - manager, err := NewManager(cfg, logger) - if err != nil { - t.Fatalf("Expected no error, got: %v", err) - } - - if manager == nil { - t.Fatal("Expected manager to be created") - } - - if len(manager.clients) != 0 { - t.Errorf("Expected 0 clients, got %d", len(manager.clients)) - } - }) -} - -func TestManager_GetActiveClient(t *testing.T) { - t.Run("returns active client when set", func(t *testing.T) { - manager, _, _, _ := createTestManager(t) - - profile := manager.config.Vaults["vault1"] - client := createTestClient(t, &profile) - manager.clients["vault1"] = client - manager.activeVault = "vault1" - - activeClient, err := manager.GetActiveClient() - if err != nil { - t.Fatalf("Expected no error, got: %v", err) - } - - if activeClient != client { - t.Error("Expected to get the active client") - } - }) - - t.Run("returns error when no active vault set", func(t *testing.T) { - manager, _, _, _ := createTestManager(t) - manager.activeVault = "" - - _, err := manager.GetActiveClient() - if err == nil { - t.Error("Expected error when no active vault is set") - } - }) - - t.Run("returns error when active vault not found", func(t *testing.T) { - manager, _, _, _ := createTestManager(t) - manager.activeVault = "non-existent" - - _, err := manager.GetActiveClient() - if err == nil { - t.Error("Expected error when active vault not found") - } - }) -} - -func TestManager_GetClient(t *testing.T) { - t.Run("returns client by name", func(t *testing.T) { - manager, _, _, _ := createTestManager(t) - - profile := manager.config.Vaults["vault1"] - client := createTestClient(t, &profile) - manager.clients["vault1"] = client - - retrievedClient, err := manager.GetClient("vault1") - if err != nil { - t.Fatalf("Expected no error, got: %v", err) - } - - if retrievedClient != client { - t.Error("Expected to get the correct client") - } - }) - - t.Run("returns error when client not found", func(t *testing.T) { - manager, _, _, _ := createTestManager(t) - - _, err := manager.GetClient("non-existent") - if err == nil { - t.Error("Expected error when client not found") - } - }) -} - -func TestManager_SwitchVault(t *testing.T) { - t.Run("switches to existing vault successfully", func(t *testing.T) { - manager, _, mockAuthMgr, _ := createTestManager(t) - - profile1 := manager.config.Vaults["vault1"] - profile2 := manager.config.Vaults["vault2"] - client1 := createTestClient(t, &profile1) - client2 := createTestClient(t, &profile2) - manager.clients["vault1"] = client1 - manager.clients["vault2"] = client2 - manager.activeVault = "vault1" - - // Set up mock authenticator to succeed - mockAuthMgr.AuthenticateFunc = func(client *api.Client, profile *config.VaultProfile) error { - return nil - } - mockAuthMgr.VerifyAuthenticationFunc = func(client *api.Client) error { - return nil - } - - err := manager.SwitchVault("vault2") - if err != nil { - t.Fatalf("Expected no error, got: %v", err) - } - - if manager.activeVault != "vault2" { - t.Errorf("Expected active vault to be 'vault2', got '%s'", manager.activeVault) - } - - if mockAuthMgr.AuthenticateCalls != 1 { - t.Errorf("Expected Authenticate to be called once, got %d calls", mockAuthMgr.AuthenticateCalls) - } - - if mockAuthMgr.VerifyAuthenticationCalls != 1 { - t.Errorf("Expected VerifyAuthentication to be called once, got %d calls", mockAuthMgr.VerifyAuthenticationCalls) - } - }) - - t.Run("returns error when vault not found", func(t *testing.T) { - manager, _, _, _ := createTestManager(t) - - err := manager.SwitchVault("non-existent") - if err == nil { - t.Error("Expected error when vault not found") - } - }) - - t.Run("returns error when authentication fails", func(t *testing.T) { - manager, _, mockAuthMgr, _ := createTestManager(t) - - profile := manager.config.Vaults["vault1"] - client := createTestClient(t, &profile) - manager.clients["vault1"] = client - - // Set up mock authenticator to fail - mockAuthMgr.AuthenticateFunc = func(client *api.Client, profile *config.VaultProfile) error { - return fmt.Errorf("authentication failed") - } - - err := manager.SwitchVault("vault1") - if err == nil { - t.Error("Expected error when authentication fails") - } - }) - - t.Run("returns error when authentication verification fails", func(t *testing.T) { - manager, _, mockAuthMgr, _ := createTestManager(t) - - profile := manager.config.Vaults["vault1"] - client := createTestClient(t, &profile) - manager.clients["vault1"] = client - - // Set up mock authenticator: authenticate succeeds but verification fails - mockAuthMgr.AuthenticateFunc = func(client *api.Client, profile *config.VaultProfile) error { - return nil - } - mockAuthMgr.VerifyAuthenticationFunc = func(client *api.Client) error { - return fmt.Errorf("verification failed") - } - - err := manager.SwitchVault("vault1") - if err == nil { - t.Error("Expected error when authentication verification fails") - } - }) - - t.Run("returns error when profile not found", func(t *testing.T) { - manager, _, _, _ := createTestManager(t) - - profile := manager.config.Vaults["vault1"] - client := createTestClient(t, &profile) - manager.clients["vault1"] = client - - // Remove profile from config but keep client - delete(manager.config.Vaults, "vault1") - - err := manager.SwitchVault("vault1") - if err == nil { - t.Error("Expected error when profile not found") - } - }) -} - -func TestManager_AddVault(t *testing.T) { - t.Run("returns error when vault already exists", func(t *testing.T) { - manager, _, _, _ := createTestManager(t) - - profile := &config.VaultProfile{ - Address: "http://duplicate.local:8200", - AuthMethod: "token", - AuthConfig: config.AuthConfig{ - Token: "token", - }, - } - - err := manager.AddVault("vault1", profile) - if err == nil { - t.Error("Expected error when vault already exists") - } - }) - - // Note: Testing successful AddVault requires file I/O for config.Save() - // which is not suitable for unit tests. Integration tests should cover this. -} - -func TestManager_ListVaults(t *testing.T) { - t.Run("returns list of vaults", func(t *testing.T) { - manager, _, _, _ := createTestManager(t) - - profile1 := manager.config.Vaults["vault1"] - profile2 := manager.config.Vaults["vault2"] - manager.clients["vault1"] = createTestClient(t, &profile1) - manager.clients["vault2"] = createTestClient(t, &profile2) - - vaults := manager.ListVaults() - if len(vaults) != 2 { - t.Errorf("Expected 2 vaults, got %d", len(vaults)) - } - - vaultMap := make(map[string]bool) - for _, v := range vaults { - vaultMap[v] = true - } - - if !vaultMap["vault1"] || !vaultMap["vault2"] { - t.Error("Expected vault1 and vault2 to be in the list") - } - }) - - t.Run("returns empty list when no vaults", func(t *testing.T) { - manager, _, _, _ := createTestManager(t) - - vaults := manager.ListVaults() - if len(vaults) != 0 { - t.Errorf("Expected 0 vaults, got %d", len(vaults)) - } - }) -} - -func TestManager_GetActiveVault(t *testing.T) { - t.Run("returns active vault name", func(t *testing.T) { - manager, _, _, _ := createTestManager(t) - manager.activeVault = "vault1" - - activeVault := manager.GetActiveVault() - if activeVault != "vault1" { - t.Errorf("Expected 'vault1', got '%s'", activeVault) - } - }) - - t.Run("returns empty string when no active vault", func(t *testing.T) { - manager, _, _, _ := createTestManager(t) - manager.activeVault = "" - - activeVault := manager.GetActiveVault() - if activeVault != "" { - t.Errorf("Expected empty string, got '%s'", activeVault) - } - }) -} - -func TestManager_GetConnectionManager(t *testing.T) { - t.Run("returns connection manager", func(t *testing.T) { - manager, mockConnMgr, _, _ := createTestManager(t) - - connMgr := manager.GetConnectionManager() - if connMgr != mockConnMgr { - t.Error("Expected to get the connection manager") - } - }) -} - -func TestManager_GetSecretsManager(t *testing.T) { - t.Run("returns secrets manager for active vault", func(t *testing.T) { - manager, _, _, _ := createTestManager(t) - - profile := manager.config.Vaults["vault1"] - client := createTestClient(t, &profile) - manager.clients["vault1"] = client - manager.activeVault = "vault1" - - secretsMgr, err := manager.GetSecretsManager() - if err != nil { - t.Fatalf("Expected no error, got: %v", err) - } - - if secretsMgr == nil { - t.Error("Expected secrets manager to be returned") - } - }) - - t.Run("returns error when no active client", func(t *testing.T) { - manager, _, _, _ := createTestManager(t) - manager.activeVault = "non-existent" - - _, err := manager.GetSecretsManager() - if err == nil { - t.Error("Expected error when no active client") - } - }) -} - -func TestManager_RefreshConnections(t *testing.T) { - t.Run("calls RefreshAllConnections", func(t *testing.T) { - manager, mockConnMgr, _, _ := createTestManager(t) - - manager.RefreshConnections() - - if mockConnMgr.RefreshAllConnectionsCalls != 1 { - t.Errorf("Expected RefreshAllConnections to be called once, got %d calls", mockConnMgr.RefreshAllConnectionsCalls) - } - }) -} - -func TestManager_GetConnectionStatus(t *testing.T) { - t.Run("returns connection status", func(t *testing.T) { - manager, mockConnMgr, _, _ := createTestManager(t) - - expectedStatus := &ConnectionStatus{ - Connected: true, - Address: "http://vault1.local:8200", - } - mockConnMgr.ConnectionStatuses["vault1"] = expectedStatus - - status, err := manager.GetConnectionStatus("vault1") - if err != nil { - t.Fatalf("Expected no error, got: %v", err) - } - - if status != expectedStatus { - t.Error("Expected to get the connection status") - } - - if mockConnMgr.GetConnectionStatusCalls != 1 { - t.Errorf("Expected GetConnectionStatus to be called once, got %d calls", mockConnMgr.GetConnectionStatusCalls) - } - }) -} - -func TestManager_GetHealthyConnections(t *testing.T) { - t.Run("returns healthy connections", func(t *testing.T) { - manager, mockConnMgr, _, _ := createTestManager(t) - - mockConnMgr.SetupConnection("vault1", true, false) - mockConnMgr.SetupConnection("vault2", true, true) - mockConnMgr.SetupConnection("vault3", false, false) - - healthy := manager.GetHealthyConnections() - - if len(healthy) != 1 { - t.Errorf("Expected 1 healthy connection, got %d", len(healthy)) - } - - if len(healthy) > 0 && healthy[0] != "vault1" { - t.Errorf("Expected vault1 to be healthy, got %s", healthy[0]) - } - - if mockConnMgr.GetHealthyConnectionsCalls != 1 { - t.Errorf("Expected GetHealthyConnections to be called once, got %d calls", mockConnMgr.GetHealthyConnectionsCalls) - } - }) -} - -func TestManager_GetConnectedConnections(t *testing.T) { - t.Run("returns connected connections", func(t *testing.T) { - manager, mockConnMgr, _, _ := createTestManager(t) - - mockConnMgr.SetupConnection("vault1", true, false) - mockConnMgr.SetupConnection("vault2", true, true) - mockConnMgr.SetupConnection("vault3", false, false) - - connected := manager.GetConnectedConnections() - - if len(connected) != 2 { - t.Errorf("Expected 2 connected connections, got %d", len(connected)) - } - - if mockConnMgr.GetConnectedConnectionsCalls != 1 { - t.Errorf("Expected GetConnectedConnections to be called once, got %d calls", mockConnMgr.GetConnectedConnectionsCalls) - } - }) -} - -func TestManager_GetVaultProfiles(t *testing.T) { - t.Run("returns all vault profiles", func(t *testing.T) { - manager, _, _, _ := createTestManager(t) - - profiles := manager.GetVaultProfiles() - - if len(profiles) != 2 { - t.Errorf("Expected 2 profiles, got %d", len(profiles)) - } - - if _, exists := profiles["vault1"]; !exists { - t.Error("Expected vault1 profile to exist") - } - - if _, exists := profiles["vault2"]; !exists { - t.Error("Expected vault2 profile to exist") - } - }) -} - -func TestManager_GetVaultProfile(t *testing.T) { - t.Run("returns specific vault profile", func(t *testing.T) { - manager, _, _, _ := createTestManager(t) - - profile, err := manager.GetVaultProfile("vault1") - if err != nil { - t.Fatalf("Expected no error, got: %v", err) - } - - if profile == nil { - t.Fatal("Expected profile to be returned") - } - - if profile.Address != "http://vault1.local:8200" { - t.Errorf("Expected address 'http://vault1.local:8200', got '%s'", profile.Address) - } - }) - - t.Run("returns error when profile not found", func(t *testing.T) { - manager, _, _, _ := createTestManager(t) - - _, err := manager.GetVaultProfile("non-existent") - if err == nil { - t.Error("Expected error when profile not found") - } - }) -} - -func TestManager_GetVaultStatus(t *testing.T) { - t.Run("returns status for all vaults", func(t *testing.T) { - manager, mockConnMgr, _, _ := createTestManager(t) - - profile1 := manager.config.Vaults["vault1"] - profile2 := manager.config.Vaults["vault2"] - manager.clients["vault1"] = createTestClient(t, &profile1) - manager.clients["vault2"] = createTestClient(t, &profile2) - manager.activeVault = "vault1" - - mockConnMgr.SetupConnection("vault1", true, false) - mockConnMgr.SetupConnection("vault2", false, false) - - statuses := manager.GetVaultStatus() - - if len(statuses) != 2 { - t.Errorf("Expected 2 statuses, got %d", len(statuses)) - } - - vault1Status, exists := statuses["vault1"] - if !exists { - t.Fatal("Expected vault1 status to exist") - } - - if vault1Status.Name != "vault1" { - t.Errorf("Expected name 'vault1', got '%s'", vault1Status.Name) - } - - if !vault1Status.Active { - t.Error("Expected vault1 to be active") - } - - if !vault1Status.Connected { - t.Error("Expected vault1 to be connected") - } - - vault2Status, exists := statuses["vault2"] - if !exists { - t.Fatal("Expected vault2 status to exist") - } - - if vault2Status.Active { - t.Error("Expected vault2 to not be active") - } - - if vault2Status.Connected { - t.Error("Expected vault2 to not be connected") - } - }) - - t.Run("handles connection status errors gracefully", func(t *testing.T) { - manager, mockConnMgr, _, _ := createTestManager(t) - - profile := manager.config.Vaults["vault1"] - manager.clients["vault1"] = createTestClient(t, &profile) - - mockConnMgr.GetConnectionStatusFunc = func(name string) (*ConnectionStatus, error) { - return nil, fmt.Errorf("connection error") - } - - statuses := manager.GetVaultStatus() - - if len(statuses) != 1 { - t.Errorf("Expected 1 status, got %d", len(statuses)) - } - - vault1Status, exists := statuses["vault1"] - if !exists { - t.Fatal("Expected vault1 status to exist") - } - - if vault1Status.Connected { - t.Error("Expected vault1 to not be connected") - } - - if vault1Status.Error != "connection error" { - t.Errorf("Expected error 'connection error', got '%s'", vault1Status.Error) - } - }) -} - -func TestManager_CreateClient(t *testing.T) { - t.Run("creates client with basic config", func(t *testing.T) { - manager, _, _, _ := createTestManager(t) - - profile := &config.VaultProfile{ - Address: "http://localhost:8200", - AuthMethod: "token", - AuthConfig: config.AuthConfig{ - Token: "test-token", - }, - } - - client, err := manager.createClient(profile) - if err != nil { - t.Fatalf("Expected no error, got: %v", err) - } - - if client == nil { - t.Fatal("Expected client to be created") - } - - if client.apiClient.Address() != "http://localhost:8200" { - t.Errorf("Expected address 'http://localhost:8200', got '%s'", client.apiClient.Address()) - } - }) - - t.Run("creates client with namespace", func(t *testing.T) { - manager, _, _, _ := createTestManager(t) - - profile := &config.VaultProfile{ - Address: "http://localhost:8200", - Namespace: "test-ns", - AuthMethod: "token", - AuthConfig: config.AuthConfig{ - Token: "test-token", - }, - } - - client, err := manager.createClient(profile) - if err != nil { - t.Fatalf("Expected no error, got: %v", err) - } - - // Note: Can't easily test namespace setting as it's internal to api.Client - if client == nil { - t.Fatal("Expected client to be created") - } - }) -} - -func TestManager_InitializeProfileClient(t *testing.T) { - t.Run("initializes profile client", func(t *testing.T) { - manager, mockConnMgr, _, _ := createTestManager(t) - - profile := &config.VaultProfile{ - Address: "http://localhost:8200", - AuthMethod: "token", - AuthConfig: config.AuthConfig{ - Token: "test-token", - }, - } - - err := manager.initializeProfileClient("test-vault", profile) - if err != nil { - t.Fatalf("Expected no error, got: %v", err) - } - - if _, exists := manager.clients["test-vault"]; !exists { - t.Error("Expected client to be added") - } - - if mockConnMgr.AddConnectionCalls != 1 { - t.Errorf("Expected AddConnection to be called once, got %d calls", mockConnMgr.AddConnectionCalls) - } - }) -} - -func TestManager_ReloadConfiguration(t *testing.T) { - // Note: ReloadConfiguration depends on config.Load() which reads from the filesystem - // and is not suitable for unit tests. This should be covered by integration tests. - // However, we can test the helper functions it uses. - t.Run("placeholder for integration test", func(t *testing.T) { - t.Skip("ReloadConfiguration requires file system access and should be tested in integration tests") - }) -} - -func TestProfileChanged(t *testing.T) { - t.Run("detects address change", func(t *testing.T) { - old := config.VaultProfile{ - Address: "http://old.local:8200", - AuthMethod: "token", - Namespace: "ns1", - AuthConfig: config.AuthConfig{Token: "token1"}, - } - new := config.VaultProfile{ - Address: "http://new.local:8200", - AuthMethod: "token", - Namespace: "ns1", - AuthConfig: config.AuthConfig{Token: "token1"}, - } - - if !profileChanged(old, new) { - t.Error("Expected profile to be detected as changed") - } - }) - - t.Run("detects auth method change", func(t *testing.T) { - old := config.VaultProfile{ - Address: "http://vault.local:8200", - AuthMethod: "token", - Namespace: "ns1", - AuthConfig: config.AuthConfig{Token: "token1"}, - } - new := config.VaultProfile{ - Address: "http://vault.local:8200", - AuthMethod: "userpass", - Namespace: "ns1", - AuthConfig: config.AuthConfig{Username: "user", Password: "pass"}, - } - - if !profileChanged(old, new) { - t.Error("Expected profile to be detected as changed") - } - }) - - t.Run("detects namespace change", func(t *testing.T) { - old := config.VaultProfile{ - Address: "http://vault.local:8200", - AuthMethod: "token", - Namespace: "ns1", - AuthConfig: config.AuthConfig{Token: "token1"}, - } - new := config.VaultProfile{ - Address: "http://vault.local:8200", - AuthMethod: "token", - Namespace: "ns2", - AuthConfig: config.AuthConfig{Token: "token1"}, - } - - if !profileChanged(old, new) { - t.Error("Expected profile to be detected as changed") - } - }) - - t.Run("detects auth config change", func(t *testing.T) { - old := config.VaultProfile{ - Address: "http://vault.local:8200", - AuthMethod: "token", - Namespace: "ns1", - AuthConfig: config.AuthConfig{Token: "token1"}, - } - new := config.VaultProfile{ - Address: "http://vault.local:8200", - AuthMethod: "token", - Namespace: "ns1", - AuthConfig: config.AuthConfig{Token: "token2"}, - } - - if !profileChanged(old, new) { - t.Error("Expected profile to be detected as changed") - } - }) - - t.Run("detects no change when profiles are identical", func(t *testing.T) { - old := config.VaultProfile{ - Address: "http://vault.local:8200", - AuthMethod: "token", - Namespace: "ns1", - AuthConfig: config.AuthConfig{Token: "token1"}, - } - new := config.VaultProfile{ - Address: "http://vault.local:8200", - AuthMethod: "token", - Namespace: "ns1", - AuthConfig: config.AuthConfig{Token: "token1"}, - } - - if profileChanged(old, new) { - t.Error("Expected profile to not be detected as changed") - } - }) -} - -func TestAuthConfigEqual(t *testing.T) { - t.Run("detects equal auth configs", func(t *testing.T) { - a := config.AuthConfig{Token: "token1"} - b := config.AuthConfig{Token: "token1"} - - if !authConfigEqual(a, b) { - t.Error("Expected auth configs to be equal") - } - }) - - t.Run("detects different auth configs", func(t *testing.T) { - a := config.AuthConfig{Token: "token1"} - b := config.AuthConfig{Token: "token2"} - - if authConfigEqual(a, b) { - t.Error("Expected auth configs to be different") - } - }) -} diff --git a/internal/vault/secrets.go b/internal/vault/secrets.go deleted file mode 100644 index c5f695a..0000000 --- a/internal/vault/secrets.go +++ /dev/null @@ -1,589 +0,0 @@ -package vault - -import ( - "context" - "encoding/json" - "fmt" - "path/filepath" - "strings" - "time" - - "github.com/sirupsen/logrus" -) - -// SecretNode represents a node in the secret tree -type SecretNode struct { - Name string `json:"name"` - Path string `json:"path"` - IsSecret bool `json:"is_secret"` - Children []*SecretNode `json:"children,omitempty"` - Data map[string]interface{} `json:"data,omitempty"` - Metadata *SecretMetadata `json:"metadata,omitempty"` -} - -// SecretMetadata contains metadata about a secret -type SecretMetadata struct { - CreatedTime time.Time `json:"created_time"` - Version int `json:"version"` - Destroyed bool `json:"destroyed"` - DeletionTime time.Time `json:"deletion_time,omitempty"` -} - -// SecretsManager manages secret operations -type SecretsManager struct { - client *Client - logger *logrus.Logger -} - -// NewSecretsManager creates a new secrets manager -func NewSecretsManager(client *Client, logger *logrus.Logger) *SecretsManager { - return &SecretsManager{ - client: client, - logger: logger, - } -} - -// ListSecrets lists all secrets at a given path -func (sm *SecretsManager) ListSecrets(path string) ([]*SecretNode, error) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - // Normalize path - path = strings.Trim(path, "/") - if path == "" { - path = "" - } - - // List secrets at the path - secret, err := sm.client.apiClient.Logical().ListWithContext(ctx, "secret/metadata/"+path) - if err != nil { - return nil, fmt.Errorf("failed to list secrets at path '%s': %w", path, err) - } - - if secret == nil || secret.Data == nil { - return []*SecretNode{}, nil - } - - // Extract keys from the response - keys, ok := secret.Data["keys"].([]interface{}) - if !ok { - return []*SecretNode{}, nil - } - - var nodes []*SecretNode - for _, key := range keys { - keyStr, ok := key.(string) - if !ok { - continue - } - - // Remove trailing slash for directories - keyStr = strings.TrimSuffix(keyStr, "/") - - node := &SecretNode{ - Name: keyStr, - Path: filepath.Join(path, keyStr), - IsSecret: !strings.HasSuffix(key.(string), "/"), - } - - nodes = append(nodes, node) - } - - return nodes, nil -} - -// GetSecret retrieves a secret and its metadata -func (sm *SecretsManager) GetSecret(path string) (*SecretNode, error) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - // Get secret data - secret, err := sm.client.apiClient.Logical().ReadWithContext(ctx, "secret/data/"+path) - if err != nil { - return nil, fmt.Errorf("failed to get secret at path '%s': %w", path, err) - } - - if secret == nil || secret.Data == nil { - return nil, fmt.Errorf("secret not found at path '%s'", path) - } - - // Get secret metadata - metadata, err := sm.client.apiClient.Logical().ReadWithContext(ctx, "secret/metadata/"+path) - if err != nil { - sm.logger.Warnf("Failed to get metadata for secret '%s': %v", path, err) - } - - node := &SecretNode{ - Name: filepath.Base(path), - Path: path, - IsSecret: true, - } - - // Extract data from KV v2 response - if data, ok := secret.Data["data"].(map[string]interface{}); ok { - node.Data = data - } else { - node.Data = secret.Data - } - - // Extract metadata if available - if metadata != nil && metadata.Data != nil { - sm.logger.Debugf("Raw metadata for %s: %+v", path, metadata.Data) - - if createdTime, ok := metadata.Data["created_time"].(string); ok { - if t, err := time.Parse(time.RFC3339, createdTime); err == nil { - node.Metadata = &SecretMetadata{ - CreatedTime: t, - } - } - } - - // Try to extract version - handle multiple numeric types - if node.Metadata == nil { - node.Metadata = &SecretMetadata{} - } - - // Try different numeric types - switch v := metadata.Data["current_version"].(type) { - case float64: - node.Metadata.Version = int(v) - sm.logger.Debugf("Version extracted as float64: %d", node.Metadata.Version) - case int: - node.Metadata.Version = v - sm.logger.Debugf("Version extracted as int: %d", node.Metadata.Version) - case int64: - node.Metadata.Version = int(v) - sm.logger.Debugf("Version extracted as int64: %d", node.Metadata.Version) - case json.Number: - if ver, err := v.Int64(); err == nil { - node.Metadata.Version = int(ver) - sm.logger.Debugf("Version extracted as json.Number: %d", node.Metadata.Version) - } - default: - sm.logger.Warnf("current_version field has unexpected type %T: %v", v, v) - } - } - - return node, nil -} - -// CreateSecret creates a new secret -func (sm *SecretsManager) CreateSecret(path string, data map[string]interface{}) error { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - // For KV v2, we need to wrap the data - secretData := map[string]interface{}{ - "data": data, - } - - _, err := sm.client.apiClient.Logical().WriteWithContext(ctx, "secret/data/"+path, secretData) - if err != nil { - return fmt.Errorf("failed to create secret at path '%s': %w", path, err) - } - - sm.logger.Infof("Created secret at path: %s", path) - return nil -} - -// UpdateSecret updates an existing secret -func (sm *SecretsManager) UpdateSecret(path string, data map[string]interface{}) error { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - // For KV v2, we need to wrap the data - secretData := map[string]interface{}{ - "data": data, - } - - _, err := sm.client.apiClient.Logical().WriteWithContext(ctx, "secret/data/"+path, secretData) - if err != nil { - return fmt.Errorf("failed to update secret at path '%s': %w", path, err) - } - - sm.logger.Infof("Updated secret at path: %s", path) - return nil -} - -// DeleteSecret deletes a secret -func (sm *SecretsManager) DeleteSecret(path string) error { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - _, err := sm.client.apiClient.Logical().DeleteWithContext(ctx, "secret/metadata/"+path) - if err != nil { - return fmt.Errorf("failed to delete secret at path '%s': %w", path, err) - } - - sm.logger.Infof("Deleted secret at path: %s", path) - return nil -} - -// BuildTree builds a complete tree structure for a given path -func (sm *SecretsManager) BuildTree(rootPath string, maxDepth int) (*SecretNode, error) { - return sm.buildTreeRecursive(rootPath, "", maxDepth, 0) -} - -// buildTreeRecursive recursively builds the secret tree -func (sm *SecretsManager) buildTreeRecursive(rootPath, currentPath string, maxDepth, currentDepth int) (*SecretNode, error) { - if currentDepth >= maxDepth { - return nil, nil - } - - // List secrets at current path - secrets, err := sm.ListSecrets(currentPath) - if err != nil { - return nil, err - } - - // Create root node - node := &SecretNode{ - Name: filepath.Base(currentPath), - Path: currentPath, - IsSecret: false, - Children: []*SecretNode{}, - } - - // If this is the root, use the provided name - if currentPath == "" { - node.Name = rootPath - if rootPath == "" { - node.Name = "secrets" - } - } - - // Process each secret/directory - for _, secret := range secrets { - if secret.IsSecret { - // This is a secret, get its data - secretNode, err := sm.GetSecret(secret.Path) - if err != nil { - sm.logger.Warnf("Failed to get secret '%s': %v", secret.Path, err) - // Add node without data if we can't retrieve it - node.Children = append(node.Children, secret) - continue - } - node.Children = append(node.Children, secretNode) - } else { - // This is a directory, recurse - childNode, err := sm.buildTreeRecursive(rootPath, secret.Path, maxDepth, currentDepth+1) - if err != nil { - sm.logger.Warnf("Failed to build tree for path '%s': %v", secret.Path, err) - continue - } - if childNode != nil { - node.Children = append(node.Children, childNode) - } - } - } - - return node, nil -} - -// SearchSecrets searches for secrets by name pattern -func (sm *SecretsManager) SearchSecrets(pattern string, rootPath string) ([]*SecretNode, error) { - var results []*SecretNode - - // This is a simple implementation - in a real scenario, you might want to - // implement a more sophisticated search that walks the entire tree - secrets, err := sm.ListSecrets(rootPath) - if err != nil { - return nil, err - } - - for _, secret := range secrets { - if strings.Contains(strings.ToLower(secret.Name), strings.ToLower(pattern)) { - if secret.IsSecret { - // Get full secret data - fullSecret, err := sm.GetSecret(secret.Path) - if err != nil { - sm.logger.Warnf("Failed to get secret '%s': %v", secret.Path, err) - results = append(results, secret) - } else { - results = append(results, fullSecret) - } - } else { - results = append(results, secret) - } - } - } - - return results, nil -} - -// AdvancedSearchOptions contains options for advanced search -type AdvancedSearchOptions struct { - Pattern string `json:"pattern"` - RootPath string `json:"root_path"` - SearchType SearchType `json:"search_type"` - KeyFilter string `json:"key_filter,omitempty"` - ValueFilter string `json:"value_filter,omitempty"` - MaxDepth int `json:"max_depth"` - CaseSensitive bool `json:"case_sensitive"` - Regex bool `json:"regex"` - Metadata map[string]string `json:"metadata,omitempty"` -} - -// SearchType defines the type of search to perform -type SearchType int - -const ( - SearchByName SearchType = iota - SearchByPath - SearchByKey - SearchByValue - SearchByMetadata - SearchAll -) - -// AdvancedSearch performs an advanced search with multiple criteria -func (sm *SecretsManager) AdvancedSearch(options *AdvancedSearchOptions) ([]*SearchResult, error) { - var results []*SearchResult - - // Build search tree if needed - rootNode, err := sm.BuildTree(options.RootPath, options.MaxDepth) - if err != nil { - return nil, fmt.Errorf("failed to build search tree: %w", err) - } - - // Perform recursive search - sm.searchRecursive(rootNode, options, &results) - - return results, nil -} - -// SearchResult represents a search result with context -type SearchResult struct { - Node *SecretNode `json:"node"` - MatchType string `json:"match_type"` - MatchValue string `json:"match_value"` - Path string `json:"path"` - Score float64 `json:"score"` -} - -// searchRecursive recursively searches through the tree -func (sm *SecretsManager) searchRecursive(node *SecretNode, options *AdvancedSearchOptions, results *[]*SearchResult) { - if node == nil { - return - } - - // Check if this node matches the search criteria - if sm.matchesSearchCriteria(node, options) { - result := &SearchResult{ - Node: node, - Path: node.Path, - MatchType: sm.getMatchType(options), - MatchValue: sm.getMatchValue(node, options), - Score: sm.calculateScore(node, options), - } - *results = append(*results, result) - } - - // Recursively search children - for _, child := range node.Children { - sm.searchRecursive(child, options, results) - } -} - -// matchesSearchCriteria checks if a node matches the search criteria -func (sm *SecretsManager) matchesSearchCriteria(node *SecretNode, options *AdvancedSearchOptions) bool { - pattern := options.Pattern - if !options.CaseSensitive { - pattern = strings.ToLower(pattern) - } - - switch options.SearchType { - case SearchByName: - return sm.matchesString(node.Name, pattern, options) - case SearchByPath: - return sm.matchesString(node.Path, pattern, options) - case SearchByKey: - return sm.matchesSecretKeys(node, pattern, options) - case SearchByValue: - return sm.matchesSecretValues(node, pattern, options) - case SearchByMetadata: - return sm.matchesMetadata(node, options) - case SearchAll: - return sm.matchesString(node.Name, pattern, options) || - sm.matchesString(node.Path, pattern, options) || - sm.matchesSecretKeys(node, pattern, options) || - sm.matchesSecretValues(node, pattern, options) - default: - return false - } -} - -// matchesString checks if a string matches the pattern -func (sm *SecretsManager) matchesString(text, pattern string, options *AdvancedSearchOptions) bool { - if !options.CaseSensitive { - text = strings.ToLower(text) - } - - if options.Regex { - // Simple regex matching - in a real implementation, you'd use regexp package - return strings.Contains(text, pattern) - } - - return strings.Contains(text, pattern) -} - -// matchesSecretKeys checks if any secret key matches the pattern -func (sm *SecretsManager) matchesSecretKeys(node *SecretNode, pattern string, options *AdvancedSearchOptions) bool { - if !node.IsSecret || node.Data == nil { - return false - } - - for key := range node.Data { - if sm.matchesString(key, pattern, options) { - return true - } - } - - return false -} - -// matchesSecretValues checks if any secret value matches the pattern -func (sm *SecretsManager) matchesSecretValues(node *SecretNode, pattern string, options *AdvancedSearchOptions) bool { - if !node.IsSecret || node.Data == nil { - return false - } - - for _, value := range node.Data { - valueStr := fmt.Sprintf("%v", value) - if sm.matchesString(valueStr, pattern, options) { - return true - } - } - - return false -} - -// matchesMetadata checks if metadata matches the criteria -func (sm *SecretsManager) matchesMetadata(node *SecretNode, options *AdvancedSearchOptions) bool { - if node.Metadata == nil || len(options.Metadata) == 0 { - return false - } - - // This is a simplified implementation - // In a real scenario, you'd check specific metadata fields - return true -} - -// getMatchType returns the type of match found -func (sm *SecretsManager) getMatchType(options *AdvancedSearchOptions) string { - switch options.SearchType { - case SearchByName: - return "name" - case SearchByPath: - return "path" - case SearchByKey: - return "key" - case SearchByValue: - return "value" - case SearchByMetadata: - return "metadata" - case SearchAll: - return "multiple" - default: - return "unknown" - } -} - -// getMatchValue returns the value that matched -func (sm *SecretsManager) getMatchValue(node *SecretNode, options *AdvancedSearchOptions) string { - switch options.SearchType { - case SearchByName: - return node.Name - case SearchByPath: - return node.Path - case SearchByKey: - // Return first matching key - if node.Data != nil { - for key := range node.Data { - if sm.matchesString(key, options.Pattern, options) { - return key - } - } - } - return "" - case SearchByValue: - // Return first matching value - if node.Data != nil { - for _, value := range node.Data { - valueStr := fmt.Sprintf("%v", value) - if sm.matchesString(valueStr, options.Pattern, options) { - return valueStr - } - } - } - return "" - default: - return node.Name - } -} - -// calculateScore calculates a relevance score for the search result -func (sm *SecretsManager) calculateScore(node *SecretNode, options *AdvancedSearchOptions) float64 { - score := 0.0 - pattern := options.Pattern - if !options.CaseSensitive { - pattern = strings.ToLower(pattern) - } - - // Base score for exact matches - if strings.EqualFold(node.Name, pattern) { - score += 100.0 - } else if strings.HasPrefix(strings.ToLower(node.Name), pattern) { - score += 50.0 - } else if strings.Contains(strings.ToLower(node.Name), pattern) { - score += 25.0 - } - - // Bonus for path matches - if strings.Contains(strings.ToLower(node.Path), pattern) { - score += 10.0 - } - - // Bonus for secret data matches - if node.IsSecret && node.Data != nil { - for key, value := range node.Data { - if strings.Contains(strings.ToLower(key), pattern) { - score += 15.0 - } - valueStr := fmt.Sprintf("%v", value) - if strings.Contains(strings.ToLower(valueStr), pattern) { - score += 5.0 - } - } - } - - return score -} - -// SearchSecretsByValue searches for secrets containing specific values -func (sm *SecretsManager) SearchSecretsByValue(valuePattern string, rootPath string) ([]*SearchResult, error) { - options := &AdvancedSearchOptions{ - Pattern: valuePattern, - RootPath: rootPath, - SearchType: SearchByValue, - MaxDepth: 10, - CaseSensitive: false, - Regex: false, - } - - return sm.AdvancedSearch(options) -} - -// SearchSecretsByKey searches for secrets containing specific keys -func (sm *SecretsManager) SearchSecretsByKey(keyPattern string, rootPath string) ([]*SearchResult, error) { - options := &AdvancedSearchOptions{ - Pattern: keyPattern, - RootPath: rootPath, - SearchType: SearchByKey, - MaxDepth: 10, - CaseSensitive: false, - Regex: false, - } - - return sm.AdvancedSearch(options) -} diff --git a/internal/vault/secrets_test.go b/internal/vault/secrets_test.go deleted file mode 100644 index 2f021f5..0000000 --- a/internal/vault/secrets_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package vault - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestSecretNode(t *testing.T) { - // Test SecretNode creation - node := &SecretNode{ - Name: "test-secret", - Path: "test/path", - IsSecret: true, - Data: map[string]interface{}{ - "key1": "value1", - "key2": "value2", - }, - } - - assert.Equal(t, "test-secret", node.Name) - assert.Equal(t, "test/path", node.Path) - assert.True(t, node.IsSecret) - assert.Len(t, node.Data, 2) - assert.Equal(t, "value1", node.Data["key1"]) -} - -func TestSecretsManager(t *testing.T) { - // This test would require a mock vault client - // For now, we'll test the structure and basic functionality - - // Create a mock client (this would need to be properly mocked) - // client := &Client{} - // sm := NewSecretsManager(client, logger) - - // Test that the manager can be created - // assert.NotNil(t, sm) - - // This is a placeholder test - in a real implementation, - // we would mock the vault client and test the actual functionality - t.Skip("Skipping test - requires mock vault client") -} - -func TestSecretMetadata(t *testing.T) { - metadata := &SecretMetadata{ - Version: 1, - Destroyed: false, - } - - assert.Equal(t, 1, metadata.Version) - assert.False(t, metadata.Destroyed) -}