diff --git a/internal/gui/staging_integration_internal_test.go b/internal/gui/staging_integration_internal_test.go index 6b4f6718..21528311 100644 --- a/internal/gui/staging_integration_internal_test.go +++ b/internal/gui/staging_integration_internal_test.go @@ -524,10 +524,9 @@ func TestFileDrainPersist(t *testing.T) { t.Parallel() localTmpDir := t.TempDir() - localFilePath := filepath.Join(localTmpDir, "stage.json") // Create file store - fileStore := file.NewStoreWithPath(localFilePath) + fileStore := file.NewStoreWithDir(localTmpDir) // Create state with entries state := staging.NewEmptyState() @@ -560,10 +559,9 @@ func TestFileDrainPersist(t *testing.T) { t.Parallel() localTmpDir := t.TempDir() - localFilePath := filepath.Join(localTmpDir, "stage.json") // Create file store with passphrase - fileStore := file.NewStoreWithPath(localFilePath) + fileStore := file.NewStoreWithDir(localTmpDir) fileStore.SetPassphrase("test-passphrase") // Create state @@ -588,7 +586,7 @@ func TestFileDrainPersist(t *testing.T) { assert.Equal(t, "secret-value", lo.FromPtr(drainedState.Entries[staging.ServiceSecret]["my-secret"].Value)) // Drain with wrong passphrase should fail - wrongStore := file.NewStoreWithPath(localFilePath) + wrongStore := file.NewStoreWithDir(localTmpDir) wrongStore.SetPassphrase("wrong-passphrase") _, err = wrongStore.Drain(context.Background(), "", true) require.Error(t, err) @@ -599,9 +597,8 @@ func TestFileDrainPersist(t *testing.T) { t.Parallel() localTmpDir := t.TempDir() - localFilePath := filepath.Join(localTmpDir, "stage.json") - fileStore := file.NewStoreWithPath(localFilePath) + fileStore := file.NewStoreWithDir(localTmpDir) // Create and write state state := staging.NewEmptyState() @@ -628,9 +625,8 @@ func TestFileDrainPersist(t *testing.T) { t.Parallel() localTmpDir := t.TempDir() - localFilePath := filepath.Join(localTmpDir, "stage.json") - fileStore := file.NewStoreWithPath(localFilePath) + fileStore := file.NewStoreWithDir(localTmpDir) // Create state with tags only (no entries) state := staging.NewEmptyState() @@ -653,9 +649,8 @@ func TestFileDrainPersist(t *testing.T) { t.Parallel() localTmpDir := t.TempDir() - localFilePath := filepath.Join(localTmpDir, "stage.json") - fileStore := file.NewStoreWithPath(localFilePath) + fileStore := file.NewStoreWithDir(localTmpDir) // Create state with both services state := staging.NewEmptyState() @@ -682,9 +677,8 @@ func TestFileDrainPersist(t *testing.T) { t.Parallel() localTmpDir := t.TempDir() - localFilePath := filepath.Join(localTmpDir, "nonexistent.json") - fileStore := file.NewStoreWithPath(localFilePath) + fileStore := file.NewStoreWithDir(localTmpDir) exists, err := fileStore.Exists() require.NoError(t, err) @@ -701,9 +695,8 @@ func TestFileDrainPersist(t *testing.T) { t.Parallel() localTmpDir := t.TempDir() - localFilePath := filepath.Join(localTmpDir, "stage.json") - fileStore := file.NewStoreWithPath(localFilePath) + fileStore := file.NewStoreWithDir(localTmpDir) // Write first state state1 := staging.NewEmptyState() diff --git a/internal/staging/cli/stash_drop_test.go b/internal/staging/cli/stash_drop_test.go index cea436fb..917a0191 100644 --- a/internal/staging/cli/stash_drop_test.go +++ b/internal/staging/cli/stash_drop_test.go @@ -2,7 +2,7 @@ package cli_test import ( "bytes" - "encoding/json" + "context" "os" "path/filepath" "testing" @@ -25,7 +25,6 @@ func TestGlobalDropRunner_Run(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "stage.json") // Write test data state := staging.NewEmptyState() @@ -39,11 +38,9 @@ func TestGlobalDropRunner_Run(t *testing.T) { Value: lo.ToPtr("secret-value"), StagedAt: time.Now(), } - data, err := json.MarshalIndent(state, "", " ") - require.NoError(t, err) - require.NoError(t, os.WriteFile(path, data, 0o600)) + fileStore := file.NewStoreWithDir(tmpDir) + require.NoError(t, fileStore.WriteState(context.Background(), "", state)) - fileStore := file.NewStoreWithPath(path) stdout := &bytes.Buffer{} runner := &cli.GlobalDropRunner{ @@ -51,12 +48,16 @@ func TestGlobalDropRunner_Run(t *testing.T) { Stdout: stdout, } - err = runner.Run() + err := runner.Run() require.NoError(t, err) assert.Contains(t, stdout.String(), "All stashed changes dropped") - // File should be deleted - _, err = os.Stat(path) + // Files should be deleted + paramPath := filepath.Join(tmpDir, "param.json") + secretPath := filepath.Join(tmpDir, "secret.json") + _, err = os.Stat(paramPath) + assert.True(t, os.IsNotExist(err)) + _, err = os.Stat(secretPath) assert.True(t, os.IsNotExist(err)) }) @@ -64,10 +65,9 @@ func TestGlobalDropRunner_Run(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "stage.json") - // Don't create the file + // Don't create any files - fileStore := file.NewStoreWithPath(path) + fileStore := file.NewStoreWithDir(tmpDir) stdout := &bytes.Buffer{} runner := &cli.GlobalDropRunner{ @@ -89,7 +89,6 @@ func TestServiceDropRunner_Run(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "stage.json") // Write test data with both services state := staging.NewEmptyState() @@ -103,11 +102,9 @@ func TestServiceDropRunner_Run(t *testing.T) { Value: lo.ToPtr("secret-value"), StagedAt: time.Now(), } - data, err := json.MarshalIndent(state, "", " ") - require.NoError(t, err) - require.NoError(t, os.WriteFile(path, data, 0o600)) + fileStore := file.NewStoreWithDir(tmpDir) + require.NoError(t, fileStore.WriteState(context.Background(), "", state)) - fileStore := file.NewStoreWithPath(path) stdout := &bytes.Buffer{} runner := &cli.ServiceDropRunner{ @@ -117,12 +114,16 @@ func TestServiceDropRunner_Run(t *testing.T) { } // Drop only param service - err = runner.Run(t.Context()) + err := runner.Run(t.Context()) require.NoError(t, err) assert.Contains(t, stdout.String(), "Stashed param changes dropped") - // File should still exist with secret service - _, err = os.Stat(path) + // param.json should be deleted, secret.json should exist + paramPath := filepath.Join(tmpDir, "param.json") + secretPath := filepath.Join(tmpDir, "secret.json") + _, err = os.Stat(paramPath) + assert.True(t, os.IsNotExist(err)) + _, err = os.Stat(secretPath) require.NoError(t, err) // Verify secret service is preserved @@ -136,7 +137,6 @@ func TestServiceDropRunner_Run(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "stage.json") // Write test data with tags state := staging.NewEmptyState() @@ -144,11 +144,9 @@ func TestServiceDropRunner_Run(t *testing.T) { Add: map[string]string{"env": "prod"}, Remove: maputil.NewSet("old-tag"), } - data, err := json.MarshalIndent(state, "", " ") - require.NoError(t, err) - require.NoError(t, os.WriteFile(path, data, 0o600)) + fileStore := file.NewStoreWithDir(tmpDir) + require.NoError(t, fileStore.WriteState(context.Background(), "", state)) - fileStore := file.NewStoreWithPath(path) stdout := &bytes.Buffer{} runner := &cli.ServiceDropRunner{ @@ -157,12 +155,13 @@ func TestServiceDropRunner_Run(t *testing.T) { Stdout: stdout, } - err = runner.Run(t.Context()) + err := runner.Run(t.Context()) require.NoError(t, err) assert.Contains(t, stdout.String(), "Stashed param changes dropped") - // File should be deleted (empty state) - _, err = os.Stat(path) + // param.json should be deleted (empty state) + paramPath := filepath.Join(tmpDir, "param.json") + _, err = os.Stat(paramPath) assert.True(t, os.IsNotExist(err)) }) @@ -170,7 +169,6 @@ func TestServiceDropRunner_Run(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "stage.json") // Write test data with only param service state := staging.NewEmptyState() @@ -179,11 +177,9 @@ func TestServiceDropRunner_Run(t *testing.T) { Value: lo.ToPtr("test-value"), StagedAt: time.Now(), } - data, err := json.MarshalIndent(state, "", " ") - require.NoError(t, err) - require.NoError(t, os.WriteFile(path, data, 0o600)) + fileStore := file.NewStoreWithDir(tmpDir) + require.NoError(t, fileStore.WriteState(context.Background(), "", state)) - fileStore := file.NewStoreWithPath(path) stdout := &bytes.Buffer{} runner := &cli.ServiceDropRunner{ @@ -193,7 +189,7 @@ func TestServiceDropRunner_Run(t *testing.T) { } // Try to drop secret service which has no entries - err = runner.Run(t.Context()) + err := runner.Run(t.Context()) require.Error(t, err) assert.Contains(t, err.Error(), "no stashed changes for secret") }) @@ -202,7 +198,6 @@ func TestServiceDropRunner_Run(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "stage.json") // Write test data with only one service state := staging.NewEmptyState() @@ -211,11 +206,9 @@ func TestServiceDropRunner_Run(t *testing.T) { Value: lo.ToPtr("test-value"), StagedAt: time.Now(), } - data, err := json.MarshalIndent(state, "", " ") - require.NoError(t, err) - require.NoError(t, os.WriteFile(path, data, 0o600)) + fileStore := file.NewStoreWithDir(tmpDir) + require.NoError(t, fileStore.WriteState(context.Background(), "", state)) - fileStore := file.NewStoreWithPath(path) stdout := &bytes.Buffer{} runner := &cli.ServiceDropRunner{ @@ -225,11 +218,12 @@ func TestServiceDropRunner_Run(t *testing.T) { } // Drop the only service - err = runner.Run(t.Context()) + err := runner.Run(t.Context()) require.NoError(t, err) // File should be deleted because state is now empty - _, err = os.Stat(path) + paramPath := filepath.Join(tmpDir, "param.json") + _, err = os.Stat(paramPath) assert.True(t, os.IsNotExist(err)) }) @@ -237,7 +231,6 @@ func TestServiceDropRunner_Run(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "stage.json") // Write test data with both services state := staging.NewEmptyState() @@ -251,11 +244,9 @@ func TestServiceDropRunner_Run(t *testing.T) { Value: lo.ToPtr("secret-value"), StagedAt: time.Now(), } - data, err := json.MarshalIndent(state, "", " ") - require.NoError(t, err) - require.NoError(t, os.WriteFile(path, data, 0o600)) + fileStore := file.NewStoreWithDir(tmpDir) + require.NoError(t, fileStore.WriteState(context.Background(), "", state)) - fileStore := file.NewStoreWithPath(path) stdout := &bytes.Buffer{} runner := &cli.ServiceDropRunner{ @@ -264,20 +255,20 @@ func TestServiceDropRunner_Run(t *testing.T) { Stdout: stdout, } - err = runner.Run(t.Context()) + err := runner.Run(t.Context()) require.NoError(t, err) - // File should still exist - _, err = os.Stat(path) + // secret.json should still exist, param.json should be deleted + paramPath := filepath.Join(tmpDir, "param.json") + secretPath := filepath.Join(tmpDir, "secret.json") + _, err = os.Stat(paramPath) + assert.True(t, os.IsNotExist(err)) + _, err = os.Stat(secretPath) require.NoError(t, err) // Read and verify remaining data - //nolint:gosec // G304: path is from t.TempDir(), safe for test - remainingData, err := os.ReadFile(path) + remainingState, err := fileStore.Drain(t.Context(), staging.ServiceSecret, true) require.NoError(t, err) - - var remainingState staging.State - require.NoError(t, json.Unmarshal(remainingData, &remainingState)) assert.Empty(t, remainingState.Entries[staging.ServiceParam]) assert.Len(t, remainingState.Entries[staging.ServiceSecret], 1) }) diff --git a/internal/staging/cli/stash_show_test.go b/internal/staging/cli/stash_show_test.go index 299b2eaf..41f8308a 100644 --- a/internal/staging/cli/stash_show_test.go +++ b/internal/staging/cli/stash_show_test.go @@ -2,7 +2,7 @@ package cli_test import ( "bytes" - "encoding/json" + "context" "os" "path/filepath" "testing" @@ -26,7 +26,6 @@ func TestStashShowRunner_Run(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "stage.json") // Write test data state := staging.NewEmptyState() @@ -40,11 +39,9 @@ func TestStashShowRunner_Run(t *testing.T) { Value: lo.ToPtr("secret-value"), StagedAt: time.Now(), } - data, err := json.MarshalIndent(state, "", " ") - require.NoError(t, err) - require.NoError(t, os.WriteFile(path, data, 0o600)) + fileStore := file.NewStoreWithDir(tmpDir) + require.NoError(t, fileStore.WriteState(context.Background(), "", state)) - fileStore := file.NewStoreWithPath(path) stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} @@ -54,7 +51,7 @@ func TestStashShowRunner_Run(t *testing.T) { Stderr: stderr, } - err = runner.Run(t.Context(), cli.StashShowOptions{}) + err := runner.Run(t.Context(), cli.StashShowOptions{}) require.NoError(t, err) assert.Contains(t, stdout.String(), "/app/config") assert.Contains(t, stdout.String(), "my-secret") @@ -65,7 +62,6 @@ func TestStashShowRunner_Run(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "stage.json") // Write test data with both services state := staging.NewEmptyState() @@ -79,11 +75,9 @@ func TestStashShowRunner_Run(t *testing.T) { Value: lo.ToPtr("secret-value"), StagedAt: time.Now(), } - data, err := json.MarshalIndent(state, "", " ") - require.NoError(t, err) - require.NoError(t, os.WriteFile(path, data, 0o600)) + fileStore := file.NewStoreWithDir(tmpDir) + require.NoError(t, fileStore.WriteState(context.Background(), "", state)) - fileStore := file.NewStoreWithPath(path) stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} @@ -93,7 +87,7 @@ func TestStashShowRunner_Run(t *testing.T) { Stderr: stderr, } - err = runner.Run(t.Context(), cli.StashShowOptions{Service: staging.ServiceParam}) + err := runner.Run(t.Context(), cli.StashShowOptions{Service: staging.ServiceParam}) require.NoError(t, err) assert.Contains(t, stdout.String(), "/app/config") assert.NotContains(t, stdout.String(), "my-secret") @@ -104,7 +98,6 @@ func TestStashShowRunner_Run(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "stage.json") // Write test data with tags state := staging.NewEmptyState() @@ -112,11 +105,9 @@ func TestStashShowRunner_Run(t *testing.T) { Add: map[string]string{"env": "prod"}, Remove: maputil.NewSet("old-tag"), } - data, err := json.MarshalIndent(state, "", " ") - require.NoError(t, err) - require.NoError(t, os.WriteFile(path, data, 0o600)) + fileStore := file.NewStoreWithDir(tmpDir) + require.NoError(t, fileStore.WriteState(context.Background(), "", state)) - fileStore := file.NewStoreWithPath(path) stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} @@ -126,7 +117,7 @@ func TestStashShowRunner_Run(t *testing.T) { Stderr: stderr, } - err = runner.Run(t.Context(), cli.StashShowOptions{}) + err := runner.Run(t.Context(), cli.StashShowOptions{}) require.NoError(t, err) assert.Contains(t, stdout.String(), "/app/config") assert.Contains(t, stdout.String(), "+1 tags") @@ -137,7 +128,6 @@ func TestStashShowRunner_Run(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "stage.json") // Write test data with tags (add only) state := staging.NewEmptyState() @@ -145,11 +135,9 @@ func TestStashShowRunner_Run(t *testing.T) { Add: map[string]string{"env": "prod", "team": "backend"}, Remove: maputil.NewSet[string](), } - data, err := json.MarshalIndent(state, "", " ") - require.NoError(t, err) - require.NoError(t, os.WriteFile(path, data, 0o600)) + fileStore := file.NewStoreWithDir(tmpDir) + require.NoError(t, fileStore.WriteState(context.Background(), "", state)) - fileStore := file.NewStoreWithPath(path) stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} @@ -159,7 +147,7 @@ func TestStashShowRunner_Run(t *testing.T) { Stderr: stderr, } - err = runner.Run(t.Context(), cli.StashShowOptions{}) + err := runner.Run(t.Context(), cli.StashShowOptions{}) require.NoError(t, err) assert.Contains(t, stdout.String(), "/app/config") assert.Contains(t, stdout.String(), "+2 tags") @@ -170,7 +158,6 @@ func TestStashShowRunner_Run(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "stage.json") // Write test data with tags (remove only) state := staging.NewEmptyState() @@ -178,11 +165,9 @@ func TestStashShowRunner_Run(t *testing.T) { Add: map[string]string{}, Remove: maputil.NewSet("deprecated", "obsolete"), } - data, err := json.MarshalIndent(state, "", " ") - require.NoError(t, err) - require.NoError(t, os.WriteFile(path, data, 0o600)) + fileStore := file.NewStoreWithDir(tmpDir) + require.NoError(t, fileStore.WriteState(context.Background(), "", state)) - fileStore := file.NewStoreWithPath(path) stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} @@ -192,7 +177,7 @@ func TestStashShowRunner_Run(t *testing.T) { Stderr: stderr, } - err = runner.Run(t.Context(), cli.StashShowOptions{}) + err := runner.Run(t.Context(), cli.StashShowOptions{}) require.NoError(t, err) assert.Contains(t, stdout.String(), "/app/config") assert.Contains(t, stdout.String(), "-2 tags") @@ -203,10 +188,9 @@ func TestStashShowRunner_Run(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "stage.json") - // Don't create the file + // Don't create any files - fileStore := file.NewStoreWithPath(path) + fileStore := file.NewStoreWithDir(tmpDir) stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} @@ -225,7 +209,6 @@ func TestStashShowRunner_Run(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "stage.json") // Write test data with only param service state := staging.NewEmptyState() @@ -234,11 +217,9 @@ func TestStashShowRunner_Run(t *testing.T) { Value: lo.ToPtr("test-value"), StagedAt: time.Now(), } - data, err := json.MarshalIndent(state, "", " ") - require.NoError(t, err) - require.NoError(t, os.WriteFile(path, data, 0o600)) + fileStore := file.NewStoreWithDir(tmpDir) + require.NoError(t, fileStore.WriteState(context.Background(), "", state)) - fileStore := file.NewStoreWithPath(path) stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} @@ -249,7 +230,7 @@ func TestStashShowRunner_Run(t *testing.T) { } // Try to show secret service which has no entries - err = runner.Run(t.Context(), cli.StashShowOptions{Service: staging.ServiceSecret}) + err := runner.Run(t.Context(), cli.StashShowOptions{Service: staging.ServiceSecret}) require.Error(t, err) assert.Contains(t, err.Error(), "no stashed changes for secret") }) @@ -258,7 +239,6 @@ func TestStashShowRunner_Run(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "stage.json") // Write test data state := staging.NewEmptyState() @@ -267,11 +247,9 @@ func TestStashShowRunner_Run(t *testing.T) { Value: lo.ToPtr("test-value"), StagedAt: time.Now(), } - data, err := json.MarshalIndent(state, "", " ") - require.NoError(t, err) - require.NoError(t, os.WriteFile(path, data, 0o600)) + fileStore := file.NewStoreWithDir(tmpDir) + require.NoError(t, fileStore.WriteState(context.Background(), "", state)) - fileStore := file.NewStoreWithPath(path) stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} @@ -281,7 +259,7 @@ func TestStashShowRunner_Run(t *testing.T) { Stderr: stderr, } - err = runner.Run(t.Context(), cli.StashShowOptions{Verbose: true}) + err := runner.Run(t.Context(), cli.StashShowOptions{Verbose: true}) require.NoError(t, err) assert.Contains(t, stdout.String(), "/app/config") // Verbose output includes the value @@ -292,7 +270,6 @@ func TestStashShowRunner_Run(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "stage.json") // Write test data state := staging.NewEmptyState() @@ -301,11 +278,9 @@ func TestStashShowRunner_Run(t *testing.T) { Value: lo.ToPtr("test-value"), StagedAt: time.Now(), } - data, err := json.MarshalIndent(state, "", " ") - require.NoError(t, err) - require.NoError(t, os.WriteFile(path, data, 0o600)) + fileStore := file.NewStoreWithDir(tmpDir) + require.NoError(t, fileStore.WriteState(context.Background(), "", state)) - fileStore := file.NewStoreWithPath(path) stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} @@ -315,11 +290,12 @@ func TestStashShowRunner_Run(t *testing.T) { Stderr: stderr, } - err = runner.Run(t.Context(), cli.StashShowOptions{}) + err := runner.Run(t.Context(), cli.StashShowOptions{}) require.NoError(t, err) - // File should still exist - _, err = os.Stat(path) + // File should still exist (param.json since we wrote param entries) + paramPath := filepath.Join(tmpDir, "param.json") + _, err = os.Stat(paramPath) assert.NoError(t, err) }) } diff --git a/internal/staging/store/file/store.go b/internal/staging/store/file/store.go index 7c80c6c0..2f8c301b 100644 --- a/internal/staging/store/file/store.go +++ b/internal/staging/store/file/store.go @@ -5,6 +5,7 @@ package file import ( "context" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -16,12 +17,13 @@ import ( ) const ( - stateFileName = "state.json" - baseDirName = ".suve" - stagingDir = "staging" + paramFileName = "param.json" + secretFileName = "secret.json" + baseDirName = ".suve" + stagingDir = "staging" ) -// fileMu protects concurrent access to the state file within a process. +// fileMu protects concurrent access to the state files within a process. // //nolint:gochecknoglobals // process-wide mutex for file access synchronization var fileMu sync.Mutex @@ -33,14 +35,15 @@ var userHomeDirFunc = os.UserHomeDir // Store manages the staging state using the filesystem. // It implements StateIO interface for drain/persist operations. +// State is split into param.json and secret.json files. type Store struct { - stateFilePath string - passphrase string + stateDir string + passphrase string } -// NewStore creates a new file Store with the default state file path. -// The state file is stored under ~/.suve/staging/{scope.Key()}/state.json -// to isolate staging state per cloud provider scope. +// NewStore creates a new file Store with the default state directory. +// The state files are stored under ~/.suve/staging/{scope.Key()}/ +// with param.json and secret.json for respective services. func NewStore(scope staging.Scope) (*Store, error) { homeDir, err := userHomeDirFunc() if err != nil { @@ -50,29 +53,29 @@ func NewStore(scope staging.Scope) (*Store, error) { stateDir := filepath.Join(homeDir, baseDirName, stagingDir, scope.Key()) return &Store{ - stateFilePath: filepath.Join(stateDir, stateFileName), + stateDir: stateDir, }, nil } -// NewStoreWithPath creates a new file Store with a custom state file path. +// NewStoreWithDir creates a new file Store with a custom state directory. // This is primarily for testing. -func NewStoreWithPath(path string) *Store { +func NewStoreWithDir(dir string) *Store { return &Store{ - stateFilePath: path, + stateDir: dir, } } // NewStoreWithPassphrase creates a new file Store with a passphrase for encryption. // This is used by drain/persist commands that need StateIO interface. func NewStoreWithPassphrase(scope staging.Scope, passphrase string) (*Store, error) { - store, err := NewStore(scope) + s, err := NewStore(scope) if err != nil { return nil, err } - store.passphrase = passphrase + s.passphrase = passphrase - return store, nil + return s, nil } // SetPassphrase sets the passphrase for encryption/decryption. @@ -81,49 +84,134 @@ func (s *Store) SetPassphrase(passphrase string) { s.passphrase = passphrase } -// Exists checks if the state file exists. +// paramPath returns the path to the param.json file. +func (s *Store) paramPath() string { + return filepath.Join(s.stateDir, paramFileName) +} + +// secretPath returns the path to the secret.json file. +func (s *Store) secretPath() string { + return filepath.Join(s.stateDir, secretFileName) +} + +// pathForService returns the file path for the given service. +func (s *Store) pathForService(service staging.Service) string { + switch service { + case staging.ServiceParam: + return s.paramPath() + case staging.ServiceSecret: + return s.secretPath() + default: + return "" + } +} + +// Exists checks if any state file exists. func (s *Store) Exists() (bool, error) { - _, err := os.Stat(s.stateFilePath) + paramExists, err := fileExists(s.paramPath()) + if err != nil { + return false, err + } + + if paramExists { + return true, nil + } + + return fileExists(s.secretPath()) +} + +// fileExists checks if a file exists. +func fileExists(path string) (bool, error) { + _, err := os.Stat(path) if err != nil { if os.IsNotExist(err) { return false, nil } - return false, fmt.Errorf("failed to check state file: %w", err) + return false, fmt.Errorf("failed to check file: %w", err) } return true, nil } -// IsEncrypted checks if the stored file is encrypted. +// IsEncrypted checks if any stored file is encrypted. +// Returns true if at least one file exists and is encrypted. func (s *Store) IsEncrypted() (bool, error) { - data, err := os.ReadFile(s.stateFilePath) + // Check param file + paramEncrypted, err := isFileEncrypted(s.paramPath()) + if err != nil { + return false, err + } + + if paramEncrypted { + return true, nil + } + + // Check secret file + return isFileEncrypted(s.secretPath()) +} + +// isFileEncrypted checks if a specific file is encrypted. +func isFileEncrypted(path string) (bool, error) { + data, err := os.ReadFile(path) //nolint:gosec // path is from internal methods, not user input if err != nil { if os.IsNotExist(err) { return false, nil } - return false, fmt.Errorf("failed to read state file: %w", err) + return false, fmt.Errorf("failed to read file: %w", err) } return crypt.IsEncrypted(data), nil } -// Drain reads the state from file, optionally deleting the file. +// Drain reads the state from file(s), optionally deleting the file(s). // This implements StateDrainer for file-based storage. // If service is empty, returns all services; otherwise filters to the specified service. -// If keep is false, the file is deleted after reading. +// If keep is false, the file(s) is deleted after reading. func (s *Store) Drain(_ context.Context, service staging.Service, keep bool) (*staging.State, error) { fileMu.Lock() defer fileMu.Unlock() - data, err := os.ReadFile(s.stateFilePath) + if service != "" { + // Read specific service file + return s.drainService(service, keep) + } + + // Read both files and merge + paramState, err := s.drainService(staging.ServiceParam, keep) + if err != nil { + return nil, err + } + + secretState, err := s.drainService(staging.ServiceSecret, keep) + if err != nil { + return nil, err + } + + // Merge states + merged := staging.NewEmptyState() + merged.Merge(paramState) + merged.Merge(secretState) + + return merged, nil +} + +// drainService reads state for a specific service. +// Must be called with fileMu held. +func (s *Store) drainService(service staging.Service, keep bool) (*staging.State, error) { + path := s.pathForService(service) + if path == "" { + return staging.NewEmptyState(), nil + } + + data, err := os.ReadFile(path) //nolint:gosec // path is from pathForService, not user input if err != nil { if os.IsNotExist(err) { return staging.NewEmptyState(), nil } - return nil, fmt.Errorf("failed to read state file: %w", err) + return nil, fmt.Errorf("failed to read %s file: %w", service, err) } // Decrypt if encrypted @@ -140,7 +228,7 @@ func (s *Store) Drain(_ context.Context, service staging.Service, keep bool) (*s var state staging.State if err := json.Unmarshal(data, &state); err != nil { - return nil, fmt.Errorf("failed to parse state file: %w", err) + return nil, fmt.Errorf("failed to parse %s file: %w", service, err) } // Initialize maps if nil @@ -148,62 +236,80 @@ func (s *Store) Drain(_ context.Context, service staging.Service, keep bool) (*s // Delete file if keep is false if !keep { - if err := os.Remove(s.stateFilePath); err != nil && !os.IsNotExist(err) { - return nil, fmt.Errorf("failed to remove state file: %w", err) + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("failed to remove %s file: %w", service, err) } } - // Filter by service if specified - if service != "" { - return state.ExtractService(service), nil - } - - return &state, nil + // Return only the requested service's data + return state.ExtractService(service), nil } -// WriteState saves the state to file. +// WriteState saves the state to file(s). // This implements StateWriter for file-based storage. -// If service is empty, writes all services; otherwise writes only the specified service. +// If service is empty, writes to both files; otherwise writes only to the specified service's file. func (s *Store) WriteState(_ context.Context, service staging.Service, state *staging.State) error { fileMu.Lock() defer fileMu.Unlock() - // Filter by service if specified + // Ensure directory exists + if err := os.MkdirAll(s.stateDir, 0o700); err != nil { //nolint:mnd // owner-only directory permissions + return fmt.Errorf("failed to create state directory: %w", err) + } + if service != "" { - state = state.ExtractService(service) + // Write specific service file + return s.writeService(service, state.ExtractService(service)) } - // Ensure directory exists - dir := filepath.Dir(s.stateFilePath) - if err := os.MkdirAll(dir, 0o700); err != nil { //nolint:mnd // owner-only directory permissions - return fmt.Errorf("failed to create state directory: %w", err) + // Write both files + var err error + + if e := s.writeService(staging.ServiceParam, state.ExtractService(staging.ServiceParam)); e != nil { + err = errors.Join(err, fmt.Errorf("param: %w", e)) + } + + if e := s.writeService(staging.ServiceSecret, state.ExtractService(staging.ServiceSecret)); e != nil { + err = errors.Join(err, fmt.Errorf("secret: %w", e)) + } + + return err +} + +// writeService writes state for a specific service. +// Must be called with fileMu held. +func (s *Store) writeService(service staging.Service, state *staging.State) error { + path := s.pathForService(service) + if path == "" { + return nil } - // Check if there are any staged changes - if state.IsEmpty() { + // Check if there are any staged changes for this service + serviceState := state.ExtractService(service) + if serviceState.IsEmpty() { // Remove file if no staged changes - if err := os.Remove(s.stateFilePath); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to remove empty state file: %w", err) + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove empty %s file: %w", service, err) } return nil } - data, err := json.MarshalIndent(state, "", " ") + data, err := json.MarshalIndent(serviceState, "", " ") if err != nil { - return fmt.Errorf("failed to marshal state: %w", err) + return fmt.Errorf("failed to marshal %s state: %w", service, err) } // Encrypt if passphrase is provided if s.passphrase != "" { data, err = crypt.Encrypt(data, s.passphrase) if err != nil { - return fmt.Errorf("failed to encrypt state: %w", err) + return fmt.Errorf("failed to encrypt %s state: %w", service, err) } } - if err := os.WriteFile(s.stateFilePath, data, 0o600); err != nil { //nolint:mnd // owner-only file permissions - return fmt.Errorf("failed to write state file: %w", err) + if err := os.WriteFile(path, data, 0o600); err != nil { //nolint:mnd // owner-only file permissions + return fmt.Errorf("failed to write %s file: %w", service, err) } return nil @@ -236,17 +342,23 @@ func initializeStateMaps(state *staging.State) { } } -// Delete removes the state file without reading its contents. +// Delete removes all state files without reading their contents. // This is useful for dropping stash when decryption is not needed. func (s *Store) Delete() error { fileMu.Lock() defer fileMu.Unlock() - if err := os.Remove(s.stateFilePath); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to remove state file: %w", err) + var err error + + if e := os.Remove(s.paramPath()); e != nil && !os.IsNotExist(e) { + err = errors.Join(err, fmt.Errorf("failed to remove param file: %w", e)) } - return nil + if e := os.Remove(s.secretPath()); e != nil && !os.IsNotExist(e) { + err = errors.Join(err, fmt.Errorf("failed to remove secret file: %w", e)) + } + + return err } // Compile-time check that Store implements FileStore. diff --git a/internal/staging/store/file/store_internal_test.go b/internal/staging/store/file/store_internal_test.go index 16b09b38..5aaad6b0 100644 --- a/internal/staging/store/file/store_internal_test.go +++ b/internal/staging/store/file/store_internal_test.go @@ -4,8 +4,10 @@ import ( "errors" "io" "os" + "path/filepath" "testing" + "github.com/samber/lo" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -132,14 +134,14 @@ func TestDrain_RemoveFileError(t *testing.T) { t.Parallel() // This test validates the error path when os.Remove fails in Drain - // We can trigger this by making the file unremovable tmpDir := t.TempDir() - dirPath := tmpDir + "/subdir" + dirPath := filepath.Join(tmpDir, "subdir") err := os.MkdirAll(dirPath, 0o750) require.NoError(t, err) - path := dirPath + "/stage.json" - err = os.WriteFile(path, []byte(`{"version":2,"entries":{"param":{},"secret":{}},"tags":{"param":{},"secret":{}}}`), 0o600) + // Write param file + paramPath := filepath.Join(dirPath, "param.json") + err = os.WriteFile(paramPath, []byte(`{"version":2,"entries":{"param":{},"secret":{}},"tags":{"param":{},"secret":{}}}`), 0o600) require.NoError(t, err) // Make directory read-only so file can't be removed @@ -149,11 +151,11 @@ func TestDrain_RemoveFileError(t *testing.T) { //nolint:gosec // G302: restore permissions for cleanup defer func() { _ = os.Chmod(dirPath, 0o755) }() - store := NewStoreWithPath(path) + store := NewStoreWithDir(dirPath) - _, err = store.Drain(t.Context(), "", false) // keep=false triggers remove + _, err = store.Drain(t.Context(), staging.ServiceParam, false) // keep=false triggers remove require.Error(t, err) - assert.Contains(t, err.Error(), "failed to remove state file") + assert.Contains(t, err.Error(), "failed to remove param file") } func TestWriteState_RemoveEmptyStateError(t *testing.T) { @@ -161,12 +163,13 @@ func TestWriteState_RemoveEmptyStateError(t *testing.T) { // Create a directory structure where we can't remove the file tmpDir := t.TempDir() - dirPath := tmpDir + "/subdir" + dirPath := filepath.Join(tmpDir, "subdir") err := os.MkdirAll(dirPath, 0o750) require.NoError(t, err) - path := dirPath + "/stage.json" - err = os.WriteFile(path, []byte(`{}`), 0o600) + // Write param file + paramPath := filepath.Join(dirPath, "param.json") + err = os.WriteFile(paramPath, []byte(`{}`), 0o600) require.NoError(t, err) // Make directory read-only so file can't be removed @@ -176,13 +179,13 @@ func TestWriteState_RemoveEmptyStateError(t *testing.T) { //nolint:gosec // G302: restore permissions for cleanup defer func() { _ = os.Chmod(dirPath, 0o755) }() - store := NewStoreWithPath(path) + store := NewStoreWithDir(dirPath) // Empty state should trigger file removal, which should fail emptyState := staging.NewEmptyState() err = store.WriteState(t.Context(), "", emptyState) require.Error(t, err) - assert.Contains(t, err.Error(), "failed to remove empty state file") + assert.Contains(t, err.Error(), "failed to remove empty param file") } // Note: This test cannot use t.Parallel() because it modifies the global randReader variable in crypt package. @@ -195,19 +198,18 @@ func TestWriteState_EncryptionError(t *testing.T) { defer crypt.ResetRandReader() tmpDir := t.TempDir() - path := tmpDir + "/stage.json" - store := NewStoreWithPath(path) + store := NewStoreWithDir(tmpDir) store.SetPassphrase("secret") // Enable encryption state := staging.NewEmptyState() state.Entries[staging.ServiceParam]["/test"] = staging.Entry{ Operation: staging.OperationCreate, - Value: strPtr("value"), + Value: lo.ToPtr("value"), } err := store.WriteState(t.Context(), "", state) require.Error(t, err) - assert.Contains(t, err.Error(), "failed to encrypt state") + assert.Contains(t, err.Error(), "failed to encrypt param state") } // errorReader is an io.Reader that returns an error. @@ -221,6 +223,175 @@ func (r *errorReader) Read(_ []byte) (n int, err error) { var _ io.Reader = (*errorReader)(nil) -func strPtr(s string) *string { - return &s +func TestPathForService_UnknownService(t *testing.T) { + t.Parallel() + + store := NewStoreWithDir(t.TempDir()) + + // Unknown service should return empty string + path := store.pathForService(staging.Service("unknown")) + assert.Empty(t, path) +} + +func TestDrainService_UnknownService(t *testing.T) { + t.Parallel() + + store := NewStoreWithDir(t.TempDir()) + + // Unknown service should return empty state (path == "") + state, err := store.drainService(staging.Service("unknown"), true) + require.NoError(t, err) + assert.True(t, state.IsEmpty()) +} + +func TestWriteService_UnknownService(t *testing.T) { + t.Parallel() + + store := NewStoreWithDir(t.TempDir()) + + // Unknown service should return nil (path == "") + err := store.writeService(staging.Service("unknown"), staging.NewEmptyState()) + assert.NoError(t, err) +} + +func TestDelete_RemoveError(t *testing.T) { + t.Parallel() + + // Create a directory with files that can't be removed + tmpDir := t.TempDir() + dirPath := filepath.Join(tmpDir, "subdir") + err := os.MkdirAll(dirPath, 0o750) + require.NoError(t, err) + + // Write both files + paramPath := filepath.Join(dirPath, "param.json") + secretPath := filepath.Join(dirPath, "secret.json") + err = os.WriteFile(paramPath, []byte(`{}`), 0o600) + require.NoError(t, err) + err = os.WriteFile(secretPath, []byte(`{}`), 0o600) + require.NoError(t, err) + + // Make directory read-only so files can't be removed + //nolint:gosec // G302: intentionally restrictive permissions for test + err = os.Chmod(dirPath, 0o555) + require.NoError(t, err) + //nolint:gosec // G302: restore permissions for cleanup + defer func() { _ = os.Chmod(dirPath, 0o755) }() + + store := NewStoreWithDir(dirPath) + + err = store.Delete() + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to remove param file") + assert.Contains(t, err.Error(), "failed to remove secret file") +} + +func TestWriteService_WriteFileError(t *testing.T) { + t.Parallel() + + // Create a read-only directory + tmpDir := t.TempDir() + dirPath := filepath.Join(tmpDir, "subdir") + err := os.MkdirAll(dirPath, 0o750) + require.NoError(t, err) + + // Make directory read-only so files can't be created + //nolint:gosec // G302: intentionally restrictive permissions for test + err = os.Chmod(dirPath, 0o555) + require.NoError(t, err) + //nolint:gosec // G302: restore permissions for cleanup + defer func() { _ = os.Chmod(dirPath, 0o755) }() + + store := NewStoreWithDir(dirPath) + + state := staging.NewEmptyState() + state.Entries[staging.ServiceParam]["/test"] = staging.Entry{ + Operation: staging.OperationCreate, + Value: lo.ToPtr("value"), + } + + err = store.writeService(staging.ServiceParam, state) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to write param file") +} + +func TestDrain_SecretStateError(t *testing.T) { + t.Parallel() + + // Create a directory with param file readable but secret file unreadable + tmpDir := t.TempDir() + + // Write param file + paramPath := filepath.Join(tmpDir, "param.json") + err := os.WriteFile(paramPath, []byte(`{"version":2,"entries":{"param":{},"secret":{}},"tags":{"param":{},"secret":{}}}`), 0o600) + require.NoError(t, err) + + // Write secret file with invalid JSON + secretPath := filepath.Join(tmpDir, "secret.json") + err = os.WriteFile(secretPath, []byte(`invalid json`), 0o600) + require.NoError(t, err) + + store := NewStoreWithDir(tmpDir) + + _, err = store.Drain(t.Context(), "", true) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse secret file") +} + +func TestWriteState_MkdirAllError(t *testing.T) { + t.Parallel() + + // Use a path that can't be created (file instead of directory) + tmpDir := t.TempDir() + filePath := filepath.Join(tmpDir, "file") + err := os.WriteFile(filePath, []byte("not a directory"), 0o600) + require.NoError(t, err) + + // Try to use the file as a directory + store := NewStoreWithDir(filepath.Join(filePath, "subdir")) + + state := staging.NewEmptyState() + state.Entries[staging.ServiceParam]["/test"] = staging.Entry{ + Operation: staging.OperationCreate, + Value: lo.ToPtr("value"), + } + + err = store.WriteState(t.Context(), "", state) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to create state directory") +} + +func TestWriteState_BothServicesError(t *testing.T) { + t.Parallel() + + // Create a read-only directory after creating it + tmpDir := t.TempDir() + dirPath := filepath.Join(tmpDir, "subdir") + err := os.MkdirAll(dirPath, 0o750) + require.NoError(t, err) + + // Make directory read-only so files can't be created + //nolint:gosec // G302: intentionally restrictive permissions for test + err = os.Chmod(dirPath, 0o555) + require.NoError(t, err) + //nolint:gosec // G302: restore permissions for cleanup + defer func() { _ = os.Chmod(dirPath, 0o755) }() + + store := NewStoreWithDir(dirPath) + + // State with both param and secret entries + state := staging.NewEmptyState() + state.Entries[staging.ServiceParam]["/test"] = staging.Entry{ + Operation: staging.OperationCreate, + Value: lo.ToPtr("param-value"), + } + state.Entries[staging.ServiceSecret]["my-secret"] = staging.Entry{ + Operation: staging.OperationCreate, + Value: lo.ToPtr("secret-value"), + } + + err = store.WriteState(t.Context(), "", state) + require.Error(t, err) + assert.Contains(t, err.Error(), "param:") + assert.Contains(t, err.Error(), "secret:") } diff --git a/internal/staging/store/file/store_test.go b/internal/staging/store/file/store_test.go index af951d05..0872ee9f 100644 --- a/internal/staging/store/file/store_test.go +++ b/internal/staging/store/file/store_test.go @@ -25,15 +25,14 @@ func TestNewStore(t *testing.T) { func TestStore_Exists(t *testing.T) { t.Parallel() - t.Run("file exists", func(t *testing.T) { + t.Run("param file exists", func(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "stage.json") - store := file.NewStoreWithPath(path) + store := file.NewStoreWithDir(tmpDir) - // Create the file - err := os.WriteFile(path, []byte(`{}`), 0o600) + // Create the param file + err := os.WriteFile(filepath.Join(tmpDir, "param.json"), []byte(`{}`), 0o600) require.NoError(t, err) exists, err := store.Exists() @@ -41,12 +40,43 @@ func TestStore_Exists(t *testing.T) { assert.True(t, exists) }) - t.Run("file does not exist", func(t *testing.T) { + t.Run("secret file exists", func(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "nonexistent.json") - store := file.NewStoreWithPath(path) + store := file.NewStoreWithDir(tmpDir) + + // Create the secret file + err := os.WriteFile(filepath.Join(tmpDir, "secret.json"), []byte(`{}`), 0o600) + require.NoError(t, err) + + exists, err := store.Exists() + require.NoError(t, err) + assert.True(t, exists) + }) + + t.Run("both files exist", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + store := file.NewStoreWithDir(tmpDir) + + // Create both files + err := os.WriteFile(filepath.Join(tmpDir, "param.json"), []byte(`{}`), 0o600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(tmpDir, "secret.json"), []byte(`{}`), 0o600) + require.NoError(t, err) + + exists, err := store.Exists() + require.NoError(t, err) + assert.True(t, exists) + }) + + t.Run("no files exist", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + store := file.NewStoreWithDir(tmpDir) exists, err := store.Exists() require.NoError(t, err) @@ -56,21 +86,19 @@ func TestStore_Exists(t *testing.T) { t.Run("stat error (not IsNotExist)", func(t *testing.T) { t.Parallel() - // Create a directory, then create a file inside, and try to stat a path - // that goes through the file as if it were a directory + // Create a file where we want a directory tmpDir := t.TempDir() filePath := filepath.Join(tmpDir, "not-a-dir") err := os.WriteFile(filePath, []byte("content"), 0o600) require.NoError(t, err) - // Try to stat a path through the file (which is not a directory) - invalidPath := filepath.Join(filePath, "stage.json") - store := file.NewStoreWithPath(invalidPath) + // Use the file as a directory (invalid) + store := file.NewStoreWithDir(filePath) exists, err := store.Exists() require.Error(t, err) assert.False(t, exists) - assert.Contains(t, err.Error(), "failed to check state file") + assert.Contains(t, err.Error(), "failed to check file") }) } @@ -89,11 +117,10 @@ func TestStore_IsEncrypted(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "stage.json") - store := file.NewStoreWithPath(path) + store := file.NewStoreWithDir(tmpDir) - // Write plain JSON - err := os.WriteFile(path, []byte(`{"version":2}`), 0o600) + // Write plain JSON to param file + err := os.WriteFile(filepath.Join(tmpDir, "param.json"), []byte(`{"version":2}`), 0o600) require.NoError(t, err) isEnc, err := store.IsEncrypted() @@ -101,17 +128,16 @@ func TestStore_IsEncrypted(t *testing.T) { assert.False(t, isEnc) }) - t.Run("encrypted", func(t *testing.T) { + t.Run("param encrypted", func(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "stage.json") - store := file.NewStoreWithPath(path) + store := file.NewStoreWithDir(tmpDir) - // Write encrypted data + // Write encrypted data to param file encrypted, err := crypt.Encrypt([]byte(`{"version":2}`), "password") require.NoError(t, err) - err = os.WriteFile(path, encrypted, 0o600) + err = os.WriteFile(filepath.Join(tmpDir, "param.json"), encrypted, 0o600) require.NoError(t, err) isEnc, err := store.IsEncrypted() @@ -119,12 +145,28 @@ func TestStore_IsEncrypted(t *testing.T) { assert.True(t, isEnc) }) - t.Run("file not exists", func(t *testing.T) { + t.Run("secret encrypted", func(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "nonexistent.json") - store := file.NewStoreWithPath(path) + store := file.NewStoreWithDir(tmpDir) + + // Write encrypted data to secret file + encrypted, err := crypt.Encrypt([]byte(`{"version":2}`), "password") + require.NoError(t, err) + err = os.WriteFile(filepath.Join(tmpDir, "secret.json"), encrypted, 0o600) + require.NoError(t, err) + + isEnc, err := store.IsEncrypted() + require.NoError(t, err) + assert.True(t, isEnc) + }) + + t.Run("files not exist", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + store := file.NewStoreWithDir(tmpDir) isEnc, err := store.IsEncrypted() require.NoError(t, err) @@ -134,44 +176,41 @@ func TestStore_IsEncrypted(t *testing.T) { t.Run("read error (not IsNotExist)", func(t *testing.T) { t.Parallel() - // Create a path through a file (not a directory) to trigger read error + // Create a file where we want a directory tmpDir := t.TempDir() filePath := filepath.Join(tmpDir, "not-a-dir") err := os.WriteFile(filePath, []byte("content"), 0o600) require.NoError(t, err) - invalidPath := filepath.Join(filePath, "stage.json") - store := file.NewStoreWithPath(invalidPath) + store := file.NewStoreWithDir(filePath) isEnc, err := store.IsEncrypted() require.Error(t, err) assert.False(t, isEnc) - assert.Contains(t, err.Error(), "failed to read state file") + assert.Contains(t, err.Error(), "failed to read file") }) } func TestStore_Drain(t *testing.T) { t.Parallel() - t.Run("empty file", func(t *testing.T) { + t.Run("empty files", func(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "stage.json") - store := file.NewStoreWithPath(path) + store := file.NewStoreWithDir(tmpDir) state, err := store.Drain(t.Context(), "", true) require.NoError(t, err) assert.True(t, state.IsEmpty()) }) - t.Run("with data keep=true", func(t *testing.T) { + t.Run("with param data keep=true", func(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "stage.json") - // Write test data + // Write test data to param file testData := `{ "version": 2, "entries": { @@ -182,19 +221,18 @@ func TestStore_Drain(t *testing.T) { }, "tags": {"param": {}, "secret": {}} }` - err := os.WriteFile(path, []byte(testData), 0o600) + err := os.WriteFile(filepath.Join(tmpDir, "param.json"), []byte(testData), 0o600) require.NoError(t, err) - store := file.NewStoreWithPath(path) + store := file.NewStoreWithDir(tmpDir) state, err := store.Drain(t.Context(), "", true) require.NoError(t, err) - assert.Equal(t, 2, state.Version) assert.Len(t, state.Entries[staging.ServiceParam], 1) assert.Equal(t, "test", lo.FromPtr(state.Entries[staging.ServiceParam]["/app/config"].Value)) // File should still exist - _, err = os.Stat(path) + _, err = os.Stat(filepath.Join(tmpDir, "param.json")) assert.NoError(t, err) }) @@ -202,19 +240,24 @@ func TestStore_Drain(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "stage.json") - // Write test data - testData := `{"version": 2, "entries": {"param": {}, "secret": {}}, "tags": {"param": {}, "secret": {}}}` - err := os.WriteFile(path, []byte(testData), 0o600) + // Write test data to both files + paramData := `{"version": 2, "entries": {"param": {"/test": {"operation": "create"}}, "secret": {}}, "tags": {"param": {}, "secret": {}}}` + //nolint:gosec // G101: This is test data, not an actual secret + secretData := `{"version": 2, "entries": {"param": {}, "secret": {"mysecret": {"operation": "create"}}}, "tags": {"param": {}, "secret": {}}}` + err := os.WriteFile(filepath.Join(tmpDir, "param.json"), []byte(paramData), 0o600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(tmpDir, "secret.json"), []byte(secretData), 0o600) require.NoError(t, err) - store := file.NewStoreWithPath(path) + store := file.NewStoreWithDir(tmpDir) _, err = store.Drain(t.Context(), "", false) require.NoError(t, err) - // File should be deleted - _, err = os.Stat(path) + // Both files should be deleted + _, err = os.Stat(filepath.Join(tmpDir, "param.json")) + assert.True(t, os.IsNotExist(err)) + _, err = os.Stat(filepath.Join(tmpDir, "secret.json")) assert.True(t, os.IsNotExist(err)) }) @@ -222,18 +265,16 @@ func TestStore_Drain(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "stage.json") - // Write encrypted data - //nolint:lll // mock function signature - testData := `{"version": 2, "entries": {"param": {"/test": {"operation": "create", "value": "secret"}}, "secret": {}}, "tags": {"param": {}, "secret": {}}}` + // Write encrypted data to param file + testData := `{"version": 2, "entries": {"param": {"/test": {"operation": "create", "value": "secret"}}, ` + + `"secret": {}}, "tags": {"param": {}, "secret": {}}}` encrypted, err := crypt.Encrypt([]byte(testData), "mypassword") require.NoError(t, err) - err = os.WriteFile(path, encrypted, 0o600) + err = os.WriteFile(filepath.Join(tmpDir, "param.json"), encrypted, 0o600) require.NoError(t, err) - // Create store with custom path and passphrase for test - store := file.NewStoreWithPath(path) + store := file.NewStoreWithDir(tmpDir) store.SetPassphrase("mypassword") state, err := store.Drain(t.Context(), "", true) @@ -245,42 +286,38 @@ func TestStore_Drain(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "stage.json") - // Write encrypted data + // Write encrypted data to param file encrypted, err := crypt.Encrypt([]byte(`{"version": 2}`), "mypassword") require.NoError(t, err) - err = os.WriteFile(path, encrypted, 0o600) + err = os.WriteFile(filepath.Join(tmpDir, "param.json"), encrypted, 0o600) require.NoError(t, err) - store := file.NewStoreWithPath(path) + store := file.NewStoreWithDir(tmpDir) _, err = store.Drain(t.Context(), "", true) assert.ErrorIs(t, err, crypt.ErrDecryptionFailed) }) - t.Run("with service filter", func(t *testing.T) { + t.Run("with service filter - param", func(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "stage.json") - // Write test data with both services - testData := `{ + // Write test data to param file + paramData := `{ "version": 2, "entries": { "param": { "/app/config": {"operation": "update", "value": "param-val"} }, - "secret": { - "my-secret": {"operation": "create", "value": "secret-val"} - } + "secret": {} }, "tags": {"param": {}, "secret": {}} }` - err := os.WriteFile(path, []byte(testData), 0o600) + err := os.WriteFile(filepath.Join(tmpDir, "param.json"), []byte(paramData), 0o600) require.NoError(t, err) - store := file.NewStoreWithPath(path) + store := file.NewStoreWithDir(tmpDir) // Drain only param service state, err := store.Drain(t.Context(), staging.ServiceParam, true) @@ -300,43 +337,40 @@ func TestStore_Drain(t *testing.T) { err := os.WriteFile(filePath, []byte("content"), 0o600) require.NoError(t, err) - invalidPath := filepath.Join(filePath, "stage.json") - store := file.NewStoreWithPath(invalidPath) + store := file.NewStoreWithDir(filePath) _, err = store.Drain(t.Context(), "", true) require.Error(t, err) - assert.Contains(t, err.Error(), "failed to read state file") + assert.Contains(t, err.Error(), "failed to read param file") }) t.Run("JSON parse error", func(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "stage.json") - // Write invalid JSON - err := os.WriteFile(path, []byte(`{invalid json`), 0o600) + // Write invalid JSON to param file + err := os.WriteFile(filepath.Join(tmpDir, "param.json"), []byte(`{invalid json`), 0o600) require.NoError(t, err) - store := file.NewStoreWithPath(path) + store := file.NewStoreWithDir(tmpDir) _, err = store.Drain(t.Context(), "", true) require.Error(t, err) - assert.Contains(t, err.Error(), "failed to parse state file") + assert.Contains(t, err.Error(), "failed to parse param file") }) t.Run("encrypted with wrong passphrase", func(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "stage.json") - // Write encrypted data + // Write encrypted data to param file encrypted, err := crypt.Encrypt([]byte(`{"version": 2}`), "correct-password") require.NoError(t, err) - err = os.WriteFile(path, encrypted, 0o600) + err = os.WriteFile(filepath.Join(tmpDir, "param.json"), encrypted, 0o600) require.NoError(t, err) - store := file.NewStoreWithPath(path) + store := file.NewStoreWithDir(tmpDir) store.SetPassphrase("wrong-password") _, err = store.Drain(t.Context(), "", true) @@ -347,12 +381,11 @@ func TestStore_Drain(t *testing.T) { func TestStore_Persist(t *testing.T) { t.Parallel() - t.Run("persist state", func(t *testing.T) { + t.Run("persist param state", func(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "stage.json") - store := file.NewStoreWithPath(path) + store := file.NewStoreWithDir(tmpDir) state := staging.NewEmptyState() state.Entries[staging.ServiceParam]["/app/config"] = staging.Entry{ @@ -363,8 +396,8 @@ func TestStore_Persist(t *testing.T) { err := store.WriteState(t.Context(), "", state) require.NoError(t, err) - // File should exist - _, err = os.Stat(path) + // Param file should exist + _, err = os.Stat(filepath.Join(tmpDir, "param.json")) require.NoError(t, err) // Read back and verify @@ -373,12 +406,11 @@ func TestStore_Persist(t *testing.T) { assert.Equal(t, "test-value", lo.FromPtr(readState.Entries[staging.ServiceParam]["/app/config"].Value)) }) - t.Run("persist empty state removes file", func(t *testing.T) { + t.Run("persist empty state removes files", func(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "stage.json") - store := file.NewStoreWithPath(path) + store := file.NewStoreWithDir(tmpDir) // First persist non-empty state state := staging.NewEmptyState() @@ -394,8 +426,8 @@ func TestStore_Persist(t *testing.T) { err = store.WriteState(t.Context(), "", emptyState) require.NoError(t, err) - // File should be removed - _, err = os.Stat(path) + // Param file should be removed + _, err = os.Stat(filepath.Join(tmpDir, "param.json")) assert.True(t, os.IsNotExist(err)) }) @@ -403,8 +435,7 @@ func TestStore_Persist(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "stage.json") - store := file.NewStoreWithPath(path) + store := file.NewStoreWithDir(tmpDir) store.SetPassphrase("secret123") state := staging.NewEmptyState() @@ -431,8 +462,7 @@ func TestStore_Persist(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "stage.json") - store := file.NewStoreWithPath(path) + store := file.NewStoreWithDir(tmpDir) state := staging.NewEmptyState() state.Entries[staging.ServiceParam]["/app/config"] = staging.Entry{ @@ -448,6 +478,12 @@ func TestStore_Persist(t *testing.T) { err := store.WriteState(t.Context(), staging.ServiceParam, state) require.NoError(t, err) + // Only param file should exist + _, err = os.Stat(filepath.Join(tmpDir, "param.json")) + require.NoError(t, err) + _, err = os.Stat(filepath.Join(tmpDir, "secret.json")) + assert.True(t, os.IsNotExist(err)) + // Read back and verify only param was persisted readState, err := store.Drain(t.Context(), "", true) require.NoError(t, err) @@ -459,8 +495,8 @@ func TestStore_Persist(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - nestedPath := filepath.Join(tmpDir, "nested", "dir", "stage.json") - store := file.NewStoreWithPath(nestedPath) + nestedDir := filepath.Join(tmpDir, "nested", "dir") + store := file.NewStoreWithDir(nestedDir) state := staging.NewEmptyState() state.Entries[staging.ServiceParam]["/app/config"] = staging.Entry{ @@ -472,7 +508,7 @@ func TestStore_Persist(t *testing.T) { require.NoError(t, err) // File should exist - _, err = os.Stat(nestedPath) + _, err = os.Stat(filepath.Join(nestedDir, "param.json")) assert.NoError(t, err) }) @@ -485,9 +521,8 @@ func TestStore_Persist(t *testing.T) { err := os.WriteFile(blocker, []byte("content"), 0o600) require.NoError(t, err) - // Try to create file inside the "blocker" file (as if it were a directory) - invalidPath := filepath.Join(blocker, "nested", "stage.json") - store := file.NewStoreWithPath(invalidPath) + // Try to use the "blocker" file as a directory + store := file.NewStoreWithDir(blocker) state := staging.NewEmptyState() state.Entries[staging.ServiceParam]["/app/config"] = staging.Entry{ @@ -504,10 +539,9 @@ func TestStore_Persist(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "nonexistent.json") - store := file.NewStoreWithPath(path) + store := file.NewStoreWithDir(tmpDir) - // Persist empty state - should not error even if file doesn't exist + // Persist empty state - should not error even if files don't exist emptyState := staging.NewEmptyState() err := store.WriteState(t.Context(), "", emptyState) require.NoError(t, err) @@ -516,15 +550,13 @@ func TestStore_Persist(t *testing.T) { t.Run("persist write error", func(t *testing.T) { t.Parallel() - // Create a directory where the file should be - WriteFile will fail tmpDir := t.TempDir() - filePath := filepath.Join(tmpDir, "stage.json") - // Create a directory with the same name as the target file - err := os.MkdirAll(filePath, 0o750) + paramPath := filepath.Join(tmpDir, "param.json") + err := os.MkdirAll(paramPath, 0o750) require.NoError(t, err) - store := file.NewStoreWithPath(filePath) + store := file.NewStoreWithDir(tmpDir) state := staging.NewEmptyState() state.Entries[staging.ServiceParam]["/app/config"] = staging.Entry{ @@ -534,60 +566,56 @@ func TestStore_Persist(t *testing.T) { err = store.WriteState(t.Context(), "", state) require.Error(t, err) - assert.Contains(t, err.Error(), "failed to write state file") + assert.Contains(t, err.Error(), "failed to write param file") }) } func TestStore_Delete(t *testing.T) { t.Parallel() - t.Run("delete existing file", func(t *testing.T) { + t.Run("delete existing files", func(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "stage.json") - store := file.NewStoreWithPath(path) + store := file.NewStoreWithDir(tmpDir) - // Create the file - err := os.WriteFile(path, []byte(`{"version":1}`), 0o600) + // Create both files + err := os.WriteFile(filepath.Join(tmpDir, "param.json"), []byte(`{"version":1}`), 0o600) require.NoError(t, err) - - // Verify file exists - _, err = os.Stat(path) + err = os.WriteFile(filepath.Join(tmpDir, "secret.json"), []byte(`{"version":1}`), 0o600) require.NoError(t, err) // Delete err = store.Delete() require.NoError(t, err) - // Verify file is deleted - _, err = os.Stat(path) + // Verify files are deleted + _, err = os.Stat(filepath.Join(tmpDir, "param.json")) + assert.True(t, os.IsNotExist(err)) + _, err = os.Stat(filepath.Join(tmpDir, "secret.json")) assert.True(t, os.IsNotExist(err)) }) - t.Run("delete non-existent file (no error)", func(t *testing.T) { + t.Run("delete non-existent files (no error)", func(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "nonexistent.json") - store := file.NewStoreWithPath(path) + store := file.NewStoreWithDir(tmpDir) - // Delete should not error even if file doesn't exist + // Delete should not error even if files don't exist err := store.Delete() require.NoError(t, err) }) - t.Run("delete encrypted file", func(t *testing.T) { + t.Run("delete encrypted files", func(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "stage.json") - // Create encrypted store - storeWithPass := file.NewStoreWithPath(path) + // Create encrypted store and write + storeWithPass := file.NewStoreWithDir(tmpDir) storeWithPass.SetPassphrase("test-passphrase") - // Write encrypted state state := staging.NewEmptyState() state.Entries[staging.ServiceParam]["/app/config"] = staging.Entry{ Operation: staging.OperationUpdate, @@ -597,17 +625,90 @@ func TestStore_Delete(t *testing.T) { require.NoError(t, err) // Verify file is encrypted - data, err := os.ReadFile(path) //nolint:gosec // Test file path from temp directory + //nolint:gosec // G304: path is from t.TempDir(), safe for test + data, err := os.ReadFile(filepath.Join(tmpDir, "param.json")) require.NoError(t, err) assert.True(t, crypt.IsEncrypted(data)) - // Create store without passphrase and delete - storeNoPass := file.NewStoreWithPath(path) + // Create store without passphrase and delete (should still work) + storeNoPass := file.NewStoreWithDir(tmpDir) err = storeNoPass.Delete() require.NoError(t, err) // Verify file is deleted - _, err = os.Stat(path) + _, err = os.Stat(filepath.Join(tmpDir, "param.json")) + assert.True(t, os.IsNotExist(err)) + }) +} + +func TestStore_BothServices(t *testing.T) { + t.Parallel() + + t.Run("persist and drain both services", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + store := file.NewStoreWithDir(tmpDir) + + state := staging.NewEmptyState() + state.Entries[staging.ServiceParam]["/app/config"] = staging.Entry{ + Operation: staging.OperationUpdate, + Value: lo.ToPtr("param-value"), + } + state.Entries[staging.ServiceSecret]["my-secret"] = staging.Entry{ + Operation: staging.OperationCreate, + Value: lo.ToPtr("secret-value"), + } + + // Persist both services + err := store.WriteState(t.Context(), "", state) + require.NoError(t, err) + + // Both files should exist + _, err = os.Stat(filepath.Join(tmpDir, "param.json")) + require.NoError(t, err) + _, err = os.Stat(filepath.Join(tmpDir, "secret.json")) + require.NoError(t, err) + + // Drain all and verify + readState, err := store.Drain(t.Context(), "", true) + require.NoError(t, err) + assert.Len(t, readState.Entries[staging.ServiceParam], 1) + assert.Len(t, readState.Entries[staging.ServiceSecret], 1) + assert.Equal(t, "param-value", lo.FromPtr(readState.Entries[staging.ServiceParam]["/app/config"].Value)) + assert.Equal(t, "secret-value", lo.FromPtr(readState.Entries[staging.ServiceSecret]["my-secret"].Value)) + }) + + t.Run("drain specific service only", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + store := file.NewStoreWithDir(tmpDir) + + state := staging.NewEmptyState() + state.Entries[staging.ServiceParam]["/app/config"] = staging.Entry{ + Operation: staging.OperationUpdate, + Value: lo.ToPtr("param-value"), + } + state.Entries[staging.ServiceSecret]["my-secret"] = staging.Entry{ + Operation: staging.OperationCreate, + Value: lo.ToPtr("secret-value"), + } + + // Persist both services + err := store.WriteState(t.Context(), "", state) + require.NoError(t, err) + + // Drain only secret, keep=false + secretState, err := store.Drain(t.Context(), staging.ServiceSecret, false) + require.NoError(t, err) + assert.Empty(t, secretState.Entries[staging.ServiceParam]) + assert.Len(t, secretState.Entries[staging.ServiceSecret], 1) + + // Secret file should be deleted, param file should remain + _, err = os.Stat(filepath.Join(tmpDir, "param.json")) + require.NoError(t, err) + _, err = os.Stat(filepath.Join(tmpDir, "secret.json")) assert.True(t, os.IsNotExist(err)) }) }