From b26794e21afa3a1d053de9801229c629a47b2ced Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 29 May 2025 09:52:05 -0700 Subject: [PATCH] test: Add comprehensive test suite for secret manager - CLI, debug, secret, and vault tests with in-memory filesystem for fast isolated testing --- internal/secret/cli_test.go | 66 ++++ internal/secret/debug_test.go | 141 ++++++++ internal/secret/secret_test.go | 188 +++++++++++ internal/secret/vault_test.go | 574 +++++++++++++++++++++++++++++++++ 4 files changed, 969 insertions(+) create mode 100644 internal/secret/cli_test.go create mode 100644 internal/secret/debug_test.go create mode 100644 internal/secret/secret_test.go create mode 100644 internal/secret/vault_test.go diff --git a/internal/secret/cli_test.go b/internal/secret/cli_test.go new file mode 100644 index 0000000..11d4204 --- /dev/null +++ b/internal/secret/cli_test.go @@ -0,0 +1,66 @@ +package secret + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/afero" +) + +func TestCLIInstanceStateDir(t *testing.T) { + // Test the CLI instance state directory functionality + fs := afero.NewMemMapFs() + + // Create a test state directory + testStateDir := "/test-state-dir" + cli := NewCLIInstanceWithStateDir(fs, testStateDir) + + if cli.GetStateDir() != testStateDir { + t.Errorf("Expected state directory %q, got %q", testStateDir, cli.GetStateDir()) + } +} + +func TestCLIInstanceWithFs(t *testing.T) { + // Test creating CLI instance with custom filesystem + fs := afero.NewMemMapFs() + cli := NewCLIInstanceWithFs(fs) + + // The state directory should be determined automatically + stateDir := cli.GetStateDir() + if stateDir == "" { + t.Error("Expected non-empty state directory") + } +} + +func TestDetermineStateDir(t *testing.T) { + // Test the determineStateDir function + + // Save original environment and restore it after test + originalStateDir := os.Getenv(EnvStateDir) + defer func() { + if originalStateDir == "" { + os.Unsetenv(EnvStateDir) + } else { + os.Setenv(EnvStateDir, originalStateDir) + } + }() + + // Test with environment variable set + testEnvDir := "/test-env-dir" + os.Setenv(EnvStateDir, testEnvDir) + + stateDir := determineStateDir("") + if stateDir != testEnvDir { + t.Errorf("Expected state directory %q from environment, got %q", testEnvDir, stateDir) + } + + // Test with custom config dir + os.Unsetenv(EnvStateDir) + customConfigDir := "/custom-config" + stateDir = determineStateDir(customConfigDir) + expectedDir := filepath.Join(customConfigDir, AppID) + if stateDir != expectedDir { + t.Errorf("Expected state directory %q with custom config, got %q", expectedDir, stateDir) + } +} diff --git a/internal/secret/debug_test.go b/internal/secret/debug_test.go new file mode 100644 index 0000000..c28c235 --- /dev/null +++ b/internal/secret/debug_test.go @@ -0,0 +1,141 @@ +package secret + +import ( + "bytes" + "log/slog" + "os" + "strings" + "syscall" + "testing" + + "golang.org/x/term" +) + +func TestDebugLogging(t *testing.T) { + // Save original GODEBUG and restore it + originalGodebug := os.Getenv("GODEBUG") + defer func() { + if originalGodebug == "" { + os.Unsetenv("GODEBUG") + } else { + os.Setenv("GODEBUG", originalGodebug) + } + // Re-initialize debug system with original setting + initDebugLogging() + }() + + tests := []struct { + name string + godebug string + expectEnabled bool + }{ + { + name: "debug enabled", + godebug: "berlin.sneak.pkg.secret", + expectEnabled: true, + }, + { + name: "debug enabled with other flags", + godebug: "other=1,berlin.sneak.pkg.secret,another=value", + expectEnabled: true, + }, + { + name: "debug disabled", + godebug: "other=1", + expectEnabled: false, + }, + { + name: "debug disabled empty", + godebug: "", + expectEnabled: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set GODEBUG + if tt.godebug == "" { + os.Unsetenv("GODEBUG") + } else { + os.Setenv("GODEBUG", tt.godebug) + } + + // Re-initialize debug system + initDebugLogging() + + // Test if debug is enabled + enabled := IsDebugEnabled() + if enabled != tt.expectEnabled { + t.Errorf("IsDebugEnabled() = %v, want %v", enabled, tt.expectEnabled) + } + + // If debug should be enabled, test that debug output works + if tt.expectEnabled { + // Capture debug output by redirecting the colorized handler + var buf bytes.Buffer + + // Override the debug logger for testing + oldLogger := debugLogger + if term.IsTerminal(int(syscall.Stderr)) { + // TTY: use colorized handler with our buffer + debugLogger = slog.New(newColorizedHandler(&buf)) + } else { + // Non-TTY: use JSON handler with our buffer + debugLogger = slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{ + Level: slog.LevelDebug, + })) + } + + // Test debug output + Debug("test message", "key", "value") + + // Restore original logger + debugLogger = oldLogger + + // Check that output was generated + output := buf.String() + if !strings.Contains(output, "test message") { + t.Errorf("Debug output does not contain expected message. Got: %s", output) + } + } + }) + } +} + +func TestDebugFunctions(t *testing.T) { + // Enable debug for testing + originalGodebug := os.Getenv("GODEBUG") + os.Setenv("GODEBUG", "berlin.sneak.pkg.secret") + defer func() { + if originalGodebug == "" { + os.Unsetenv("GODEBUG") + } else { + os.Setenv("GODEBUG", originalGodebug) + } + initDebugLogging() + }() + + initDebugLogging() + + if !IsDebugEnabled() { + t.Skip("Debug not enabled, skipping debug function tests") + } + + // Test that debug functions don't panic and can be called + t.Run("Debug", func(t *testing.T) { + Debug("test debug message") + Debug("test with args", "key", "value", "number", 42) + }) + + t.Run("DebugF", func(t *testing.T) { + DebugF("formatted message: %s %d", "test", 123) + }) + + t.Run("DebugWith", func(t *testing.T) { + DebugWith("structured message", + slog.String("string_key", "string_value"), + slog.Int("int_key", 42), + slog.Bool("bool_key", true), + ) + }) +} diff --git a/internal/secret/secret_test.go b/internal/secret/secret_test.go new file mode 100644 index 0000000..ddae356 --- /dev/null +++ b/internal/secret/secret_test.go @@ -0,0 +1,188 @@ +package secret + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "git.eeqj.de/sneak/secret/pkg/agehd" + "github.com/spf13/afero" +) + +func TestPerSecretKeyFunctionality(t *testing.T) { + // Create an in-memory filesystem for testing + fs := afero.NewMemMapFs() + + // Set up test environment variables + oldMnemonic := os.Getenv(EnvMnemonic) + defer func() { + if oldMnemonic == "" { + os.Unsetenv(EnvMnemonic) + } else { + os.Setenv(EnvMnemonic, oldMnemonic) + } + }() + + // Set test mnemonic for direct encryption/decryption + testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + os.Setenv(EnvMnemonic, testMnemonic) + + // Set up a test vault structure + baseDir := "/test-config/berlin.sneak.pkg.secret" + stateDir := baseDir + vaultDir := filepath.Join(baseDir, "vaults.d", "test-vault") + + // Create vault directory structure + err := fs.MkdirAll(filepath.Join(vaultDir, "secrets.d"), 0700) + if err != nil { + t.Fatalf("Failed to create vault directory: %v", err) + } + + // Generate a long-term keypair for the vault using the test mnemonic + ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0) + if err != nil { + t.Fatalf("Failed to generate long-term identity: %v", err) + } + + // Write long-term public key + ltPubKeyPath := filepath.Join(vaultDir, "pub.age") + err = afero.WriteFile( + fs, + ltPubKeyPath, + []byte(ltIdentity.Recipient().String()), + 0600, + ) + if err != nil { + t.Fatalf("Failed to write long-term public key: %v", err) + } + + // Set current vault + currentVaultPath := filepath.Join(baseDir, "currentvault") + err = afero.WriteFile(fs, currentVaultPath, []byte(vaultDir), 0600) + if err != nil { + t.Fatalf("Failed to set current vault: %v", err) + } + + // Create vault instance + vault := NewVault(fs, "test-vault", stateDir) + + // Test data + secretName := "test-secret" + secretValue := []byte("this is a test secret value") + + // Test AddSecret + t.Run("AddSecret", func(t *testing.T) { + err := vault.AddSecret(secretName, secretValue, false) + if err != nil { + t.Fatalf("AddSecret failed: %v", err) + } + + // Verify that all expected files were created + secretDir := filepath.Join(vaultDir, "secrets.d", secretName) + + // Check value.age exists (the new per-secret key architecture format) + secretExists, err := afero.Exists( + fs, + filepath.Join(secretDir, "value.age"), + ) + if err != nil || !secretExists { + t.Fatalf("value.age file was not created") + } + + // Check metadata exists + metadataExists, err := afero.Exists( + fs, + filepath.Join(secretDir, "secret-metadata.json"), + ) + if err != nil || !metadataExists { + t.Fatalf("secret-metadata.json file was not created") + } + + t.Logf("All expected files created successfully") + }) + + // Test GetSecret + t.Run("GetSecret", func(t *testing.T) { + retrievedValue, err := vault.GetSecret(secretName) + if err != nil { + t.Fatalf("GetSecret failed: %v", err) + } + + if !bytes.Equal(retrievedValue, secretValue) { + t.Fatalf( + "Retrieved value doesn't match original. Expected: %s, Got: %s", + string(secretValue), + string(retrievedValue), + ) + } + + t.Logf("Successfully retrieved secret: %s", string(retrievedValue)) + }) + + // Test that different secrets get different keys + t.Run("DifferentSecretsGetDifferentKeys", func(t *testing.T) { + secretName2 := "test-secret-2" + secretValue2 := []byte("this is another test secret") + + // Add second secret + err := vault.AddSecret(secretName2, secretValue2, false) + if err != nil { + t.Fatalf("Failed to add second secret: %v", err) + } + + // Verify both secrets can be retrieved correctly + value1, err := vault.GetSecret(secretName) + if err != nil { + t.Fatalf("Failed to retrieve first secret: %v", err) + } + + value2, err := vault.GetSecret(secretName2) + if err != nil { + t.Fatalf("Failed to retrieve second secret: %v", err) + } + + if !bytes.Equal(value1, secretValue) { + t.Fatalf("First secret value mismatch") + } + + if !bytes.Equal(value2, secretValue2) { + t.Fatalf("Second secret value mismatch") + } + + t.Logf( + "Successfully verified that different secrets have different keys", + ) + }) +} + +func TestSecretNameValidation(t *testing.T) { + tests := []struct { + name string + valid bool + }{ + {"valid-name", true}, + {"valid.name", true}, + {"valid_name", true}, + {"valid/path/name", true}, + {"123valid", true}, + {"", false}, + {"Invalid-Name", false}, // uppercase not allowed + {"invalid name", false}, // space not allowed + {"invalid@name", false}, // @ not allowed + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := isValidSecretName(test.name) + if result != test.valid { + t.Errorf( + "isValidSecretName(%q) = %v, want %v", + test.name, + result, + test.valid, + ) + } + }) + } +} diff --git a/internal/secret/vault_test.go b/internal/secret/vault_test.go new file mode 100644 index 0000000..53b1475 --- /dev/null +++ b/internal/secret/vault_test.go @@ -0,0 +1,574 @@ +package secret + +import ( + "os" + "path/filepath" + "testing" + "time" + + "filippo.io/age" + "git.eeqj.de/sneak/secret/pkg/agehd" + "github.com/spf13/afero" +) + +const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + +// setupTestEnvironment sets up the test environment with mock filesystem and environment variables +func setupTestEnvironment(t *testing.T) (afero.Fs, func()) { + // Create mock filesystem + fs := afero.NewMemMapFs() + + // Save original environment variables + oldMnemonic := os.Getenv(EnvMnemonic) + oldPassphrase := os.Getenv(EnvUnlockPassphrase) + oldStateDir := os.Getenv(EnvStateDir) + + // Create a real temporary directory for the state directory + // This is needed because GetStateDir checks the real filesystem + realTempDir, err := os.MkdirTemp("", "secret-test-*") + if err != nil { + t.Fatalf("Failed to create real temp directory: %v", err) + } + + // Set test environment variables + os.Setenv(EnvMnemonic, testMnemonic) + os.Setenv(EnvUnlockPassphrase, "test-passphrase") + os.Setenv(EnvStateDir, realTempDir) + + // Also create the directory structure in the mock filesystem + err = fs.MkdirAll(realTempDir, 0700) + if err != nil { + t.Fatalf("Failed to create test state directory in mock fs: %v", err) + } + + // Create vaults.d directory in both filesystems + vaultsDir := filepath.Join(realTempDir, "vaults.d") + err = os.MkdirAll(vaultsDir, 0700) + if err != nil { + t.Fatalf("Failed to create real vaults directory: %v", err) + } + err = fs.MkdirAll(vaultsDir, 0700) + if err != nil { + t.Fatalf("Failed to create mock vaults directory: %v", err) + } + + // Return cleanup function + cleanup := func() { + // Clean up real temporary directory + os.RemoveAll(realTempDir) + + // Restore environment variables + if oldMnemonic == "" { + os.Unsetenv(EnvMnemonic) + } else { + os.Setenv(EnvMnemonic, oldMnemonic) + } + if oldPassphrase == "" { + os.Unsetenv(EnvUnlockPassphrase) + } else { + os.Setenv(EnvUnlockPassphrase, oldPassphrase) + } + if oldStateDir == "" { + os.Unsetenv(EnvStateDir) + } else { + os.Setenv(EnvStateDir, oldStateDir) + } + } + + return fs, cleanup +} + +func TestCreateVault(t *testing.T) { + fs, cleanup := setupTestEnvironment(t) + defer cleanup() + + stateDir := "/test-secret-state" + + // Test creating a new vault + vault, err := CreateVault(fs, stateDir, "test-vault") + if err != nil { + t.Fatalf("Failed to create vault: %v", err) + } + + if vault.Name != "test-vault" { + t.Errorf("Expected vault name 'test-vault', got '%s'", vault.Name) + } + + // Check that vault directory was created + vaultDir, err := vault.GetDirectory() + if err != nil { + t.Fatalf("Failed to get vault directory: %v", err) + } + + exists, err := afero.DirExists(fs, vaultDir) + if err != nil { + t.Fatalf("Error checking vault directory: %v", err) + } + if !exists { + t.Errorf("Vault directory was not created") + } + + // Check that subdirectories were created + secretsDir := filepath.Join(vaultDir, "secrets.d") + exists, err = afero.DirExists(fs, secretsDir) + if err != nil { + t.Fatalf("Error checking secrets directory: %v", err) + } + if !exists { + t.Errorf("Secrets directory was not created") + } + + unlockKeysDir := filepath.Join(vaultDir, "unlock.d") + exists, err = afero.DirExists(fs, unlockKeysDir) + if err != nil { + t.Fatalf("Error checking unlock keys directory: %v", err) + } + if !exists { + t.Errorf("Unlock keys directory was not created") + } + + // Test creating a vault that already exists + _, err = CreateVault(fs, stateDir, "test-vault") + if err == nil { + t.Errorf("Expected error when creating vault that already exists") + } +} + +func TestSelectVault(t *testing.T) { + fs, cleanup := setupTestEnvironment(t) + defer cleanup() + + stateDir := "/test-secret-state" + + // Create a vault first + _, err := CreateVault(fs, stateDir, "test-vault") + if err != nil { + t.Fatalf("Failed to create vault: %v", err) + } + + // Test selecting the vault + err = SelectVault(fs, stateDir, "test-vault") + if err != nil { + t.Fatalf("Failed to select vault: %v", err) + } + + // Check that currentvault symlink was created with correct target + currentVaultPath := filepath.Join(stateDir, "currentvault") + + content, err := afero.ReadFile(fs, currentVaultPath) + if err != nil { + t.Fatalf("Failed to read currentvault symlink: %v", err) + } + + expectedPath := filepath.Join(stateDir, "vaults.d", "test-vault") + if string(content) != expectedPath { + t.Errorf("Expected currentvault to point to '%s', got '%s'", expectedPath, string(content)) + } + + // Test selecting a vault that doesn't exist + err = SelectVault(fs, stateDir, "nonexistent-vault") + if err == nil { + t.Errorf("Expected error when selecting nonexistent vault") + } +} + +func TestGetCurrentVault(t *testing.T) { + fs, cleanup := setupTestEnvironment(t) + defer cleanup() + + stateDir := "/test-secret-state" + + // Create and select a vault + _, err := CreateVault(fs, stateDir, "test-vault") + if err != nil { + t.Fatalf("Failed to create vault: %v", err) + } + + err = SelectVault(fs, stateDir, "test-vault") + if err != nil { + t.Fatalf("Failed to select vault: %v", err) + } + + // Test getting current vault + vault, err := GetCurrentVault(fs, stateDir) + if err != nil { + t.Fatalf("Failed to get current vault: %v", err) + } + + if vault.Name != "test-vault" { + t.Errorf("Expected current vault name 'test-vault', got '%s'", vault.Name) + } +} + +func TestListVaults(t *testing.T) { + fs, cleanup := setupTestEnvironment(t) + defer cleanup() + + stateDir := "/test-secret-state" + + // Initially no vaults + vaults, err := ListVaults(fs, stateDir) + if err != nil { + t.Fatalf("Failed to list vaults: %v", err) + } + if len(vaults) != 0 { + t.Errorf("Expected no vaults initially, got %d", len(vaults)) + } + + // Create multiple vaults + vaultNames := []string{"vault1", "vault2", "vault3"} + for _, name := range vaultNames { + _, err := CreateVault(fs, stateDir, name) + if err != nil { + t.Fatalf("Failed to create vault %s: %v", name, err) + } + } + + // List vaults + vaults, err = ListVaults(fs, stateDir) + if err != nil { + t.Fatalf("Failed to list vaults: %v", err) + } + + if len(vaults) != len(vaultNames) { + t.Errorf("Expected %d vaults, got %d", len(vaultNames), len(vaults)) + } + + // Check that all created vaults are in the list + vaultMap := make(map[string]bool) + for _, vault := range vaults { + vaultMap[vault] = true + } + + for _, name := range vaultNames { + if !vaultMap[name] { + t.Errorf("Expected vault '%s' in list", name) + } + } +} + +func TestVaultGetDirectory(t *testing.T) { + fs, cleanup := setupTestEnvironment(t) + defer cleanup() + + stateDir := "/test-secret-state" + + vault := NewVault(fs, "test-vault", stateDir) + + dir, err := vault.GetDirectory() + if err != nil { + t.Fatalf("Failed to get vault directory: %v", err) + } + + expectedDir := "/test-secret-state/vaults.d/test-vault" + if dir != expectedDir { + t.Errorf("Expected directory '%s', got '%s'", expectedDir, dir) + } +} + +func TestAddSecret(t *testing.T) { + fs, cleanup := setupTestEnvironment(t) + defer cleanup() + + stateDir := "/test-secret-state" + + // Create vault and set up long-term key + vault, err := CreateVault(fs, stateDir, "test-vault") + if err != nil { + t.Fatalf("Failed to create vault: %v", err) + } + + // We need to create a long-term public key for the vault + // This simulates what happens during vault initialization + err = setupVaultWithLongTermKey(fs, vault) + if err != nil { + t.Fatalf("Failed to setup vault with long-term key: %v", err) + } + + // Test adding a secret + secretName := "test-secret" + secretValue := []byte("super secret value") + + err = vault.AddSecret(secretName, secretValue, false) + if err != nil { + t.Fatalf("Failed to add secret: %v", err) + } + + // Check that secret directory was created + vaultDir, _ := vault.GetDirectory() + secretDir := filepath.Join(vaultDir, "secrets.d", secretName) + exists, err := afero.DirExists(fs, secretDir) + if err != nil { + t.Fatalf("Error checking secret directory: %v", err) + } + if !exists { + t.Errorf("Secret directory was not created") + } + + // Check that encrypted secret file exists + secretFile := filepath.Join(secretDir, "value.age") + exists, err = afero.Exists(fs, secretFile) + if err != nil { + t.Fatalf("Error checking secret file: %v", err) + } + if !exists { + t.Errorf("Secret file was not created") + } + + // Check that metadata file exists + metadataFile := filepath.Join(secretDir, "secret-metadata.json") + exists, err = afero.Exists(fs, metadataFile) + if err != nil { + t.Fatalf("Error checking metadata file: %v", err) + } + if !exists { + t.Errorf("Metadata file was not created") + } + + // Test adding a duplicate secret without force flag + err = vault.AddSecret(secretName, secretValue, false) + if err == nil { + t.Errorf("Expected error when adding duplicate secret without force flag") + } + + // Test adding a duplicate secret with force flag + err = vault.AddSecret(secretName, []byte("new value"), true) + if err != nil { + t.Errorf("Failed to overwrite secret with force flag: %v", err) + } + + // Test adding secret with slash in name (should be encoded) + err = vault.AddSecret("path/to/secret", []byte("value"), false) + if err != nil { + t.Fatalf("Failed to add secret with slash in name: %v", err) + } + + // Check that the slash was encoded as percent + encodedSecretDir := filepath.Join(vaultDir, "secrets.d", "path%to%secret") + exists, err = afero.DirExists(fs, encodedSecretDir) + if err != nil { + t.Fatalf("Error checking encoded secret directory: %v", err) + } + if !exists { + t.Errorf("Encoded secret directory was not created") + } +} + +func TestGetSecret(t *testing.T) { + fs, cleanup := setupTestEnvironment(t) + defer cleanup() + + stateDir := "/test-secret-state" + + // Create vault and set up long-term key + vault, err := CreateVault(fs, stateDir, "test-vault") + if err != nil { + t.Fatalf("Failed to create vault: %v", err) + } + + err = setupVaultWithLongTermKey(fs, vault) + if err != nil { + t.Fatalf("Failed to setup vault with long-term key: %v", err) + } + + // Add a secret + secretName := "test-secret" + secretValue := []byte("super secret value") + err = vault.AddSecret(secretName, secretValue, false) + if err != nil { + t.Fatalf("Failed to add secret: %v", err) + } + + // Test getting the secret (using mnemonic environment variable) + retrievedValue, err := vault.GetSecret(secretName) + if err != nil { + t.Fatalf("Failed to get secret: %v", err) + } + + if string(retrievedValue) != string(secretValue) { + t.Errorf("Expected secret value '%s', got '%s'", string(secretValue), string(retrievedValue)) + } + + // Test getting a nonexistent secret + _, err = vault.GetSecret("nonexistent-secret") + if err == nil { + t.Errorf("Expected error when getting nonexistent secret") + } + + // Test getting secret with encoded name + encodedSecretName := "path/to/secret" + encodedSecretValue := []byte("encoded secret value") + err = vault.AddSecret(encodedSecretName, encodedSecretValue, false) + if err != nil { + t.Fatalf("Failed to add encoded secret: %v", err) + } + + retrievedEncodedValue, err := vault.GetSecret(encodedSecretName) + if err != nil { + t.Fatalf("Failed to get encoded secret: %v", err) + } + + if string(retrievedEncodedValue) != string(encodedSecretValue) { + t.Errorf("Expected encoded secret value '%s', got '%s'", string(encodedSecretValue), string(retrievedEncodedValue)) + } +} + +func TestListSecrets(t *testing.T) { + fs, cleanup := setupTestEnvironment(t) + defer cleanup() + + stateDir := "/test-secret-state" + + // Create vault and set up long-term key + vault, err := CreateVault(fs, stateDir, "test-vault") + if err != nil { + t.Fatalf("Failed to create vault: %v", err) + } + + err = setupVaultWithLongTermKey(fs, vault) + if err != nil { + t.Fatalf("Failed to setup vault with long-term key: %v", err) + } + + // Initially no secrets + secrets, err := vault.ListSecrets() + if err != nil { + t.Fatalf("Failed to list secrets: %v", err) + } + if len(secrets) != 0 { + t.Errorf("Expected no secrets initially, got %d", len(secrets)) + } + + // Add multiple secrets + secretNames := []string{"secret1", "secret2", "path/to/secret3"} + for _, name := range secretNames { + err := vault.AddSecret(name, []byte("value for "+name), false) + if err != nil { + t.Fatalf("Failed to add secret %s: %v", name, err) + } + } + + // List secrets + secrets, err = vault.ListSecrets() + if err != nil { + t.Fatalf("Failed to list secrets: %v", err) + } + + if len(secrets) != len(secretNames) { + t.Errorf("Expected %d secrets, got %d", len(secretNames), len(secrets)) + } + + // Check that all added secrets are in the list (names should be decoded) + secretMap := make(map[string]bool) + for _, secret := range secrets { + secretMap[secret] = true + } + + for _, name := range secretNames { + if !secretMap[name] { + t.Errorf("Expected secret '%s' in list", name) + } + } +} + +func TestGetSecretMetadata(t *testing.T) { + fs, cleanup := setupTestEnvironment(t) + defer cleanup() + + stateDir := "/test-secret-state" + + // Create vault and set up long-term key + vault, err := CreateVault(fs, stateDir, "test-vault") + if err != nil { + t.Fatalf("Failed to create vault: %v", err) + } + + err = setupVaultWithLongTermKey(fs, vault) + if err != nil { + t.Fatalf("Failed to setup vault with long-term key: %v", err) + } + + // Add a secret + secretName := "test-secret" + secretValue := []byte("super secret value") + beforeAdd := time.Now() + err = vault.AddSecret(secretName, secretValue, false) + if err != nil { + t.Fatalf("Failed to add secret: %v", err) + } + afterAdd := time.Now() + + // Get secret object and its metadata + secretObj, err := vault.GetSecretObject(secretName) + if err != nil { + t.Fatalf("Failed to get secret object: %v", err) + } + + metadata := secretObj.GetMetadata() + + if metadata.Name != secretName { + t.Errorf("Expected metadata name '%s', got '%s'", secretName, metadata.Name) + } + + // Check that timestamps are reasonable + if metadata.CreatedAt.Before(beforeAdd) || metadata.CreatedAt.After(afterAdd) { + t.Errorf("CreatedAt timestamp is out of expected range") + } + + if metadata.UpdatedAt.Before(beforeAdd) || metadata.UpdatedAt.After(afterAdd) { + t.Errorf("UpdatedAt timestamp is out of expected range") + } + + // Test getting metadata for nonexistent secret + _, err = vault.GetSecretObject("nonexistent-secret") + if err == nil { + t.Errorf("Expected error when getting secret object for nonexistent secret") + } +} + +func TestListUnlockKeys(t *testing.T) { + fs, cleanup := setupTestEnvironment(t) + defer cleanup() + + stateDir := "/test-secret-state" + + // Create vault + vault, err := CreateVault(fs, stateDir, "test-vault") + if err != nil { + t.Fatalf("Failed to create vault: %v", err) + } + + // Initially no unlock keys + keys, err := vault.ListUnlockKeys() + if err != nil { + t.Fatalf("Failed to list unlock keys: %v", err) + } + if len(keys) != 0 { + t.Errorf("Expected no unlock keys initially, got %d", len(keys)) + } +} + +// setupVaultWithLongTermKey sets up a vault with a long-term public key for testing +func setupVaultWithLongTermKey(fs afero.Fs, vault *Vault) error { + // This simulates what happens during vault initialization + // We derive a long-term keypair from the test mnemonic + ltIdentity, err := vault.deriveLongTermIdentity() + if err != nil { + return err + } + + // Store the long-term public key in the vault + vaultDir, err := vault.GetDirectory() + if err != nil { + return err + } + + ltPubKey := ltIdentity.Recipient().String() + return afero.WriteFile(fs, filepath.Join(vaultDir, "pub.age"), []byte(ltPubKey), 0600) +} + +// deriveLongTermIdentity is a helper method to derive the long-term identity for testing +func (v *Vault) deriveLongTermIdentity() (*age.X25519Identity, error) { + // Use agehd.DeriveIdentity with the test mnemonic + return agehd.DeriveIdentity(testMnemonic, 0) +}