diff --git a/pkg/secrets/vault/secrets.go b/pkg/secrets/vault/secrets.go index 8ae4f42cd..a50e1b704 100644 --- a/pkg/secrets/vault/secrets.go +++ b/pkg/secrets/vault/secrets.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "path" + "regexp" "strings" ) @@ -13,14 +15,77 @@ const ( vaultValueField = "value" ) +var pathKeyPattern = regexp.MustCompile(`^[a-zA-Z0-9._/-]+$`) + // Sentinel errors for secret operations. var ( ErrSecretNotFound = errors.New("secret not found") ErrUnexpectedDataFormat = errors.New("unexpected secret data format") ErrKeyNotFound = errors.New("key not found in secret") ErrValueNotString = errors.New("value is not a string") + ErrInvalidPathKey = errors.New("invalid path-based secret key") ) +// validatePathKey ensures path-based secret keys stay inside the configured namespace. +func validatePathKey(key string) error { + // Keep this guard as a defensive invariant for direct/helper usage and + // future call sites, even though current public methods usually reject + // empty keys before calling validatePathKey. + if key == "" { + return fmt.Errorf("%w: empty key", ErrInvalidPathKey) + } + + // Vault paths are URL-style logical paths and must always use '/' + // separators, even on Windows hosts. + if strings.Contains(key, "\\") { + return fmt.Errorf("%w: backslash is not allowed: %q", ErrInvalidPathKey, key) + } + + if !pathKeyPattern.MatchString(key) { + return fmt.Errorf("%w: key contains unsupported characters: %q", ErrInvalidPathKey, key) + } + + if strings.HasPrefix(key, "/") { + return fmt.Errorf("%w: absolute path not allowed: %q", ErrInvalidPathKey, key) + } + + cleanKey := path.Clean(key) + if cleanKey == "." || cleanKey == ".." || strings.HasPrefix(cleanKey, "../") { + return fmt.Errorf("%w: traversal not allowed: %q", ErrInvalidPathKey, key) + } + + if cleanKey != key { + return fmt.Errorf("%w: non-normalized path not allowed: %q", ErrInvalidPathKey, key) + } + + segments := strings.Split(key, "/") + for _, segment := range segments { + if segment == "" || segment == "." || segment == ".." { + return fmt.Errorf("%w: invalid path segment in key: %q", ErrInvalidPathKey, key) + } + } + + return nil +} + +// buildScopedSecretPath joins a validated key under the configured base path and +// enforces that the normalized result stays inside the same namespace. +func buildScopedSecretPath(basePath, key string) (string, error) { + if err := validatePathKey(key); err != nil { + return "", err + } + + base := path.Clean("/" + basePath) + joined := path.Clean(path.Join(base, key)) + prefix := base + "/" + + if !strings.HasPrefix(joined, prefix) { + return "", fmt.Errorf("%w: path escapes base namespace: %q", ErrInvalidPathKey, key) + } + + return strings.TrimPrefix(joined, "/"), nil +} + // GetKeyValue reads a value from Vault. // If the key contains "/", it's treated as a separate path: {basePath}/{key} with data stored under "value". // Otherwise, it's stored in {basePath}/keys with the key as a field name. @@ -30,15 +95,24 @@ func (c *Client) GetKeyValue(key string) (string, error) { var ( secretPath string dataKey string + err error ) if strings.Contains(key, "/") { + secretPath, err = buildScopedSecretPath(c.path, key) + if err != nil { + return "", err + } + // Path-based storage: {basePath}/{key} with "value" field - secretPath = c.path + "/" + key dataKey = vaultValueField } else { // Key-based storage: {basePath}/keys with key as field name - secretPath = c.path + "/keys" + secretPath, err = buildScopedSecretPath(c.path, "keys") + if err != nil { + return "", err + } + dataKey = key } @@ -75,44 +149,57 @@ func (c *Client) GetKeyValue(key string) (string, error) { // Otherwise, it's stored in {basePath}/keys with the key as a field name. func (c *Client) SetKeyValue(key, value string) error { ctx := context.Background() + if strings.Contains(key, "/") { + return c.setPathBasedKeyValue(ctx, key, value) + } - var ( - secretPath string - secretData map[string]interface{} - ) + return c.setFieldKeyValue(ctx, key, value) +} - if strings.Contains(key, "/") { - // Path-based storage: {basePath}/{key} with "value" field - secretPath = c.path + "/" + key - secretData = map[string]interface{}{ - vaultDataField: map[string]interface{}{ - vaultValueField: value, - }, - } - } else { - // Key-based storage: {basePath}/keys with key as field name - secretPath = c.path + "/keys" +func (c *Client) setPathBasedKeyValue(ctx context.Context, key, value string) error { + secretPath, err := buildScopedSecretPath(c.path, key) + if err != nil { + return err + } + + // Path-based storage: {basePath}/{key} with "value" field + secretData := map[string]interface{}{ + vaultDataField: map[string]interface{}{ + vaultValueField: value, + }, + } - // Read existing secret to preserve other keys - secret, err := c.client.Logical().ReadWithContext(ctx, secretPath) - data := make(map[string]interface{}) + _, err = c.client.Logical().WriteWithContext(ctx, secretPath, secretData) - if err == nil && secret != nil { - // Secret exists, preserve existing data - if d, ok := secret.Data[vaultDataField].(map[string]interface{}); ok { - data = d - } - } + return err +} - // Set or update the specific key - data[key] = value +func (c *Client) setFieldKeyValue(ctx context.Context, key, value string) error { + // Key-based storage: {basePath}/keys with key as field name + secretPath, err := buildScopedSecretPath(c.path, "keys") + if err != nil { + return err + } + + // Read existing secret to preserve other keys + secret, err := c.client.Logical().ReadWithContext(ctx, secretPath) + data := make(map[string]interface{}) - secretData = map[string]interface{}{ - vaultDataField: data, + if err == nil && secret != nil { + // Secret exists, preserve existing data + if d, ok := secret.Data[vaultDataField].(map[string]interface{}); ok { + data = d } } - _, err := c.client.Logical().WriteWithContext(ctx, secretPath, secretData) + // Set or update the specific key + data[key] = value + + secretData := map[string]interface{}{ + vaultDataField: data, + } + + _, err = c.client.Logical().WriteWithContext(ctx, secretPath, secretData) return err } @@ -124,17 +211,26 @@ func (c *Client) DeleteKeyValue(key string) error { ctx := context.Background() if strings.Contains(key, "/") { + metadataBasePath := strings.Replace(c.path, "/data/", "/metadata/", 1) + + metadataPath, err := buildScopedSecretPath(metadataBasePath, key) + if err != nil { + return err + } + // Path-based storage: permanently delete the secret at {basePath}/{key} // For KV v2, we need to delete metadata to permanently remove (not just soft delete) // Convert secret/data/console/... to secret/metadata/console/... - metadataPath := strings.Replace(c.path, "/data/", "/metadata/", 1) + "/" + key - _, err := c.client.Logical().DeleteWithContext(ctx, metadataPath) + _, err = c.client.Logical().DeleteWithContext(ctx, metadataPath) return err } // Key-based storage: remove from {basePath}/keys - secretPath := c.path + "/keys" + secretPath, err := buildScopedSecretPath(c.path, "keys") + if err != nil { + return err + } // Read existing secret secret, err := c.client.Logical().ReadWithContext(ctx, secretPath) @@ -166,8 +262,16 @@ func (c *Client) DeleteKeyValue(key string) error { // GetObject retrieves a map of string values from a path-based secret. // The key must contain "/" to specify the path: {basePath}/{key}. func (c *Client) GetObject(key string) (map[string]string, error) { + if !strings.Contains(key, "/") { + return nil, fmt.Errorf("%w: object key must contain '/': %q", ErrInvalidPathKey, key) + } + + secretPath, err := buildScopedSecretPath(c.path, key) + if err != nil { + return nil, err + } + ctx := context.Background() - secretPath := c.path + "/" + key secret, err := c.client.Logical().ReadWithContext(ctx, secretPath) if err != nil { @@ -198,8 +302,16 @@ func (c *Client) GetObject(key string) (map[string]string, error) { // SetObject stores a map of string values at a path-based secret. // The key must contain "/" to specify the path: {basePath}/{key}. func (c *Client) SetObject(key string, data map[string]string) error { + if !strings.Contains(key, "/") { + return fmt.Errorf("%w: object key must contain '/': %q", ErrInvalidPathKey, key) + } + + secretPath, err := buildScopedSecretPath(c.path, key) + if err != nil { + return err + } + ctx := context.Background() - secretPath := c.path + "/" + key // Convert map[string]string to map[string]interface{} dataInterface := make(map[string]interface{}) @@ -211,7 +323,7 @@ func (c *Client) SetObject(key string, data map[string]string) error { vaultDataField: dataInterface, } - _, err := c.client.Logical().WriteWithContext(ctx, secretPath, secretData) + _, err = c.client.Logical().WriteWithContext(ctx, secretPath, secretData) return err } diff --git a/pkg/secrets/vault/secrets_test.go b/pkg/secrets/vault/secrets_test.go index 0f86f30bc..7bb56773d 100644 --- a/pkg/secrets/vault/secrets_test.go +++ b/pkg/secrets/vault/secrets_test.go @@ -1,113 +1,542 @@ package secrets import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" "testing" - "github.com/hashicorp/vault/api" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" + + "github.com/device-management-toolkit/console/config" ) -// MockLogical mocks the Vault Logical API. -type MockLogical struct { - mock.Mock +const testVaultPath = "secret/data/console" + +type vaultHandlerFunc func(method, path string, body []byte) (int, string) + +func newTestVaultClient(t *testing.T, handler vaultHandlerFunc) *Client { + t.Helper() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + status, resp := handler(r.Method, r.URL.Path, body) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write([]byte(resp)) + })) + t.Cleanup(server.Close) + + cfg := &config.Secrets{Address: server.URL, Token: "test-token", Path: testVaultPath} + client, err := NewClient(cfg) + assert.NoError(t, err) + + return client } -func (m *MockLogical) ReadWithContext(ctx interface{}, path string) (*api.Secret, error) { - args := m.Called(ctx, path) - if args.Get(0) == nil { - return nil, args.Error(1) +func TestValidatePathKey(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + key string + wantError bool + }{ + {name: "valid nested", key: "certificates/root", wantError: false}, + {name: "valid simple", key: "encryption-key", wantError: false}, + {name: "valid underscore and dot", key: "certs/root_v1.pem", wantError: false}, + {name: "empty", key: "", wantError: true}, + {name: "absolute-like", key: "/certificates/root", wantError: true}, + {name: "parent traversal", key: "../other/path", wantError: true}, + {name: "inline traversal", key: "certs/../../other", wantError: true}, + {name: "dot segment", key: "./certs/root", wantError: true}, + {name: "double slash", key: "certs//root", wantError: true}, + {name: "backslash separator", key: "certs\\root", wantError: true}, + {name: "query char", key: "certs/root?x=1", wantError: true}, + {name: "fragment char", key: "certs/root#frag", wantError: true}, + {name: "percent encoded slash", key: "certs%2froot", wantError: true}, } - result, ok := args.Get(0).(*api.Secret) - if !ok { - return nil, args.Error(1) + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := validatePathKey(tc.key) + if tc.wantError { + assert.Error(t, err) + assert.True(t, errors.Is(err, ErrInvalidPathKey), "expected ErrInvalidPathKey, got %v", err) + + return + } + + assert.NoError(t, err) + }) } +} + +func TestValidatePathKey_TraversalBranch(t *testing.T) { + t.Parallel() + + err := validatePathKey("..") + assert.Error(t, err) + assert.True(t, errors.Is(err, ErrInvalidPathKey), "expected ErrInvalidPathKey, got %v", err) + assert.Contains(t, err.Error(), "traversal not allowed") +} + +func TestValidatePathKey_NonNormalizedBranch(t *testing.T) { + t.Parallel() - return result, args.Error(1) + err := validatePathKey("a//b") + assert.Error(t, err) + assert.True(t, errors.Is(err, ErrInvalidPathKey), "expected ErrInvalidPathKey, got %v", err) + assert.Contains(t, err.Error(), "non-normalized path not allowed") } -func (m *MockLogical) WriteWithContext(ctx interface{}, path string, data map[string]interface{}) (*api.Secret, error) { - args := m.Called(ctx, path, data) - if args.Get(0) == nil { - return nil, args.Error(1) +func TestBuildScopedSecretPath(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + basePath string + key string + wantPath string + wantError bool + }{ + { + name: "valid nested path", + basePath: "secret/data/console", + key: "certificates/root", + wantPath: "secret/data/console/certificates/root", + wantError: false, + }, + { + name: "base path with trailing slash", + basePath: "secret/data/console/", + key: "keys", + wantPath: "secret/data/console/keys", + wantError: false, + }, + { + name: "reject traversal", + basePath: "secret/data/console", + key: "../other/path", + wantError: true, + }, + { + name: "reject absolute-like", + basePath: "secret/data/console", + key: "/certificates/root", + wantError: true, + }, + { + name: "reject unsupported chars", + basePath: "secret/data/console", + key: "certs/root?x=1", + wantError: true, + }, + { + name: "reject when base namespace is empty", + basePath: "", + key: "keys", + wantError: true, + }, } - result, ok := args.Get(0).(*api.Secret) - if !ok { - return nil, args.Error(1) + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + gotPath, err := buildScopedSecretPath(tc.basePath, tc.key) + if tc.wantError { + assert.Error(t, err) + assert.True(t, errors.Is(err, ErrInvalidPathKey), "expected ErrInvalidPathKey, got %v", err) + + return + } + + assert.NoError(t, err) + assert.Equal(t, tc.wantPath, gotPath) + }) } +} + +func TestPathBasedMethods_RejectInvalidPathKey(t *testing.T) { + t.Parallel() + + client := &Client{path: DefaultSecretPath} + invalidPathKey := "../other/path" + + _, err := client.GetKeyValue(invalidPathKey) + assert.Error(t, err) + assert.True(t, errors.Is(err, ErrInvalidPathKey), "expected ErrInvalidPathKey, got %v", err) + + err = client.SetKeyValue(invalidPathKey, "value") + assert.Error(t, err) + assert.True(t, errors.Is(err, ErrInvalidPathKey), "expected ErrInvalidPathKey, got %v", err) + + err = client.DeleteKeyValue(invalidPathKey) + assert.Error(t, err) + assert.True(t, errors.Is(err, ErrInvalidPathKey), "expected ErrInvalidPathKey, got %v", err) - return result, args.Error(1) + _, err = client.GetObject(invalidPathKey) + assert.Error(t, err) + assert.True(t, errors.Is(err, ErrInvalidPathKey), "expected ErrInvalidPathKey, got %v", err) + + err = client.SetObject(invalidPathKey, map[string]string{"k": "v"}) + assert.Error(t, err) + assert.True(t, errors.Is(err, ErrInvalidPathKey), "expected ErrInvalidPathKey, got %v", err) } -func (m *MockLogical) DeleteWithContext(ctx interface{}, path string) (*api.Secret, error) { - args := m.Called(ctx, path) - if args.Get(0) == nil { - return nil, args.Error(1) - } +func TestGetKeyValue_PathBasedSuccess(t *testing.T) { + t.Parallel() - result, ok := args.Get(0).(*api.Secret) - if !ok { - return nil, args.Error(1) - } + client := newTestVaultClient(t, func(method, reqPath string, _ []byte) (int, string) { + assert.Equal(t, http.MethodGet, method) + assert.Equal(t, "/v1/secret/data/console/certs/root", reqPath) + + return http.StatusOK, `{"data":{"data":{"value":"abc123"}}}` + }) - return result, args.Error(1) + value, err := client.GetKeyValue("certs/root") + assert.NoError(t, err) + assert.Equal(t, "abc123", value) } -// MockVaultClient wraps api.Client with mockable methods. -type MockVaultClient struct { - mock.Mock - logicalAPI *MockLogical +func TestGetKeyValue_FieldBasedSuccess(t *testing.T) { + t.Parallel() + + client := newTestVaultClient(t, func(method, reqPath string, _ []byte) (int, string) { + assert.Equal(t, http.MethodGet, method) + assert.Equal(t, "/v1/secret/data/console/keys", reqPath) + + return http.StatusOK, `{"data":{"data":{"device-password":"secret"}}}` + }) + + value, err := client.GetKeyValue("device-password") + assert.NoError(t, err) + assert.Equal(t, "secret", value) } -func (m *MockVaultClient) Logical() interface{} { - return m.logicalAPI +func TestGetKeyValue_FieldBasedInvalidBasePath(t *testing.T) { + t.Parallel() + + // Empty base path causes buildScopedSecretPath("", "keys") to fail, + // which should be returned from GetKeyValue's field-based branch. + client := &Client{path: ""} + + _, err := client.GetKeyValue("device-password") + assert.Error(t, err) + assert.True(t, errors.Is(err, ErrInvalidPathKey), "expected ErrInvalidPathKey, got %v", err) + assert.Contains(t, err.Error(), "path escapes base namespace") } -func TestGetKeyValue_Success(t *testing.T) { +func TestGetKeyValue_ReadError(t *testing.T) { t.Parallel() - mockLogical := new(MockLogical) - secretData := map[string]interface{}{ - "data": map[string]interface{}{ - "test-key": "test-value", - }, - } - mockSecret := &api.Secret{Data: secretData} + client := newTestVaultClient(t, func(_, _ string, _ []byte) (int, string) { + return http.StatusInternalServerError, `{"errors":["backend down"]}` + }) - // New path structure: {basePath}/keys - mockLogical.On("ReadWithContext", mock.Anything, "secret/data/console/keys").Return(mockSecret, nil) + _, err := client.GetKeyValue("device-password") + assert.Error(t, err) +} - mockVaultAPI := &api.Client{} - client, _ := NewClient(nil, WithClient(mockVaultAPI)) +func TestGetKeyValue_SecretNotFound(t *testing.T) { + t.Parallel() + + client := newTestVaultClient(t, func(_, _ string, _ []byte) (int, string) { + return http.StatusNotFound, `{"errors":[]}` + }) - // We need to inject the mock logical API - since we can't do this directly, - // we'll test the logic conceptually - // This test demonstrates the expected behavior - assert.NotNil(t, client) + _, err := client.GetKeyValue("device-password") + assert.Error(t, err) + assert.True(t, errors.Is(err, ErrSecretNotFound), "expected ErrSecretNotFound, got %v", err) +} + +func TestGetKeyValue_UnexpectedDataFormat(t *testing.T) { + t.Parallel() + + client := newTestVaultClient(t, func(_, _ string, _ []byte) (int, string) { + return http.StatusOK, `{"data":{"not_data":{"device-password":"secret"}}}` + }) + + _, err := client.GetKeyValue("device-password") + assert.Error(t, err) + assert.True(t, errors.Is(err, ErrUnexpectedDataFormat), "expected ErrUnexpectedDataFormat, got %v", err) } func TestGetKeyValue_KeyNotFound(t *testing.T) { t.Parallel() - // Tests that GetKeyValue returns appropriate error when key not found - // This would require mocking the Vault API which is complex - assert.True(t, true) + client := newTestVaultClient(t, func(_, _ string, _ []byte) (int, string) { + return http.StatusOK, `{"data":{"data":{"some-other-key":"secret"}}}` + }) + + _, err := client.GetKeyValue("device-password") + assert.Error(t, err) + assert.True(t, errors.Is(err, ErrKeyNotFound), "expected ErrKeyNotFound, got %v", err) +} + +func TestGetKeyValue_ValueNotString(t *testing.T) { + t.Parallel() + + client := newTestVaultClient(t, func(_, _ string, _ []byte) (int, string) { + return http.StatusOK, `{"data":{"data":{"device-password":42}}}` + }) + + _, err := client.GetKeyValue("device-password") + assert.Error(t, err) + assert.True(t, errors.Is(err, ErrValueNotString), "expected ErrValueNotString, got %v", err) } -func TestSetKeyValue_Success(t *testing.T) { +func TestSetKeyValue_PathBasedSuccess(t *testing.T) { t.Parallel() - // Tests that SetKeyValue successfully writes a key-value pair - // This would require mocking the Vault API - assert.True(t, true) + client := newTestVaultClient(t, func(method, reqPath string, body []byte) (int, string) { + assert.Equal(t, http.MethodPut, method) + assert.Equal(t, "/v1/secret/data/console/certs/root", reqPath) + assert.Contains(t, string(body), `"value":"abc123"`) + + return http.StatusOK, `{"data":{}}` + }) + + err := client.SetKeyValue("certs/root", "abc123") + assert.NoError(t, err) } -func TestDeleteKeyValue_Success(t *testing.T) { +func TestSetKeyValue_FieldBasedSuccessAndMerge(t *testing.T) { t.Parallel() - // Tests that DeleteKeyValue successfully deletes a key - // This would require mocking the Vault API - assert.True(t, true) + client := newTestVaultClient(t, func(method, reqPath string, body []byte) (int, string) { + switch method { + case http.MethodGet: + assert.Equal(t, "/v1/secret/data/console/keys", reqPath) + + return http.StatusOK, `{"data":{"data":{"existing":"keep"}}}` + case http.MethodPut: + assert.Equal(t, "/v1/secret/data/console/keys", reqPath) + + var payload map[string]map[string]string + + err := json.Unmarshal(body, &payload) + assert.NoError(t, err) + assert.Equal(t, "keep", payload["data"]["existing"]) + assert.Equal(t, "new-value", payload["data"]["new-key"]) + + return http.StatusOK, `{"data":{}}` + default: + return http.StatusMethodNotAllowed, `{"errors":["unexpected method"]}` + } + }) + + err := client.SetKeyValue("new-key", "new-value") + assert.NoError(t, err) +} + +func TestSetKeyValue_FieldBasedReadErrorStillWrites(t *testing.T) { + t.Parallel() + + client := newTestVaultClient(t, func(method, reqPath string, body []byte) (int, string) { + switch method { + case http.MethodGet: + return http.StatusInternalServerError, `{"errors":["backend down"]}` + case http.MethodPut: + assert.Equal(t, "/v1/secret/data/console/keys", reqPath) + assert.Contains(t, string(body), `"new-key":"new-value"`) + assert.False(t, strings.Contains(string(body), "existing")) + + return http.StatusOK, `{"data":{}}` + default: + return http.StatusMethodNotAllowed, `{"errors":["unexpected method"]}` + } + }) + + err := client.SetKeyValue("new-key", "new-value") + assert.NoError(t, err) +} + +func TestSetKeyValue_FieldBasedInvalidBasePath(t *testing.T) { + t.Parallel() + + // Empty base path causes buildScopedSecretPath("", "keys") to fail, + // which should be returned from setFieldKeyValue via SetKeyValue. + client := &Client{path: ""} + + err := client.SetKeyValue("new-key", "new-value") + assert.Error(t, err) + assert.True(t, errors.Is(err, ErrInvalidPathKey), "expected ErrInvalidPathKey, got %v", err) + assert.Contains(t, err.Error(), "path escapes base namespace") +} + +func TestDeleteKeyValue_PathBasedSuccess(t *testing.T) { + t.Parallel() + + client := newTestVaultClient(t, func(method, reqPath string, _ []byte) (int, string) { + assert.Equal(t, http.MethodDelete, method) + assert.Equal(t, "/v1/secret/metadata/console/certs/root", reqPath) + + return http.StatusNoContent, `` + }) + + err := client.DeleteKeyValue("certs/root") + assert.NoError(t, err) +} + +func TestDeleteKeyValue_FieldBasedReadError(t *testing.T) { + t.Parallel() + + client := newTestVaultClient(t, func(_, _ string, _ []byte) (int, string) { + return http.StatusInternalServerError, `{"errors":["backend down"]}` + }) + + err := client.DeleteKeyValue("device-password") + assert.Error(t, err) +} + +func TestDeleteKeyValue_FieldBasedInvalidBasePath(t *testing.T) { + t.Parallel() + + // Empty base path causes buildScopedSecretPath("", "keys") to fail, + // which should be returned from DeleteKeyValue's field-based branch. + client := &Client{path: ""} + + err := client.DeleteKeyValue("device-password") + assert.Error(t, err) + assert.True(t, errors.Is(err, ErrInvalidPathKey), "expected ErrInvalidPathKey, got %v", err) + assert.Contains(t, err.Error(), "path escapes base namespace") +} + +func TestDeleteKeyValue_FieldBasedSecretNotFound(t *testing.T) { + t.Parallel() + + client := newTestVaultClient(t, func(_, _ string, _ []byte) (int, string) { + return http.StatusNotFound, `{"errors":[]}` + }) + + err := client.DeleteKeyValue("device-password") + assert.Error(t, err) + assert.True(t, errors.Is(err, ErrSecretNotFound), "expected ErrSecretNotFound, got %v", err) +} + +func TestDeleteKeyValue_FieldBasedUnexpectedDataFormat(t *testing.T) { + t.Parallel() + + client := newTestVaultClient(t, func(method, _ string, _ []byte) (int, string) { + switch method { + case http.MethodGet: + return http.StatusOK, `{"data":{"not_data":{"k":"v"}}}` + case http.MethodPut: + return http.StatusOK, `{"data":{}}` + default: + return http.StatusMethodNotAllowed, `{"errors":["unexpected method"]}` + } + }) + + err := client.DeleteKeyValue("device-password") + assert.Error(t, err) + assert.True(t, errors.Is(err, ErrUnexpectedDataFormat), "expected ErrUnexpectedDataFormat, got %v", err) +} + +func TestDeleteKeyValue_FieldBasedSuccess(t *testing.T) { + t.Parallel() + + client := newTestVaultClient(t, func(method, _ string, body []byte) (int, string) { + switch method { + case http.MethodGet: + return http.StatusOK, `{"data":{"data":{"device-password":"secret","other":"keep"}}}` + case http.MethodPut: + assert.Contains(t, string(body), `"other":"keep"`) + assert.False(t, strings.Contains(string(body), "device-password")) + + return http.StatusOK, `{"data":{}}` + default: + return http.StatusMethodNotAllowed, `{"errors":["unexpected method"]}` + } + }) + + err := client.DeleteKeyValue("device-password") + assert.NoError(t, err) +} + +func TestGetObject_Success(t *testing.T) { + t.Parallel() + + client := newTestVaultClient(t, func(method, reqPath string, _ []byte) (int, string) { + assert.Equal(t, http.MethodGet, method) + assert.Equal(t, "/v1/secret/data/console/certs/root", reqPath) + + return http.StatusOK, `{"data":{"data":{"cert":"pem-data","version":7}}}` + }) + + obj, err := client.GetObject("certs/root") + assert.NoError(t, err) + assert.Equal(t, map[string]string{"cert": "pem-data"}, obj) +} + +func TestGetObject_SecretNotFound(t *testing.T) { + t.Parallel() + + client := newTestVaultClient(t, func(_, _ string, _ []byte) (int, string) { + return http.StatusNotFound, `{"errors":[]}` + }) + + _, err := client.GetObject("certs/root") + assert.Error(t, err) + assert.True(t, errors.Is(err, ErrSecretNotFound), "expected ErrSecretNotFound, got %v", err) +} + +func TestGetObject_UnexpectedDataFormat(t *testing.T) { + t.Parallel() + + client := newTestVaultClient(t, func(_, _ string, _ []byte) (int, string) { + return http.StatusOK, `{"data":{"not_data":{"cert":"pem"}}}` + }) + + _, err := client.GetObject("certs/root") + assert.Error(t, err) + assert.True(t, errors.Is(err, ErrUnexpectedDataFormat), "expected ErrUnexpectedDataFormat, got %v", err) +} + +func TestSetObject_Success(t *testing.T) { + t.Parallel() + + client := newTestVaultClient(t, func(method, reqPath string, body []byte) (int, string) { + assert.Equal(t, http.MethodPut, method) + assert.Equal(t, "/v1/secret/data/console/certs/root", reqPath) + assert.Contains(t, string(body), `"cert":"pem-data"`) + assert.Contains(t, string(body), `"key":"key-data"`) + + return http.StatusOK, `{"data":{}}` + }) + + err := client.SetObject("certs/root", map[string]string{"cert": "pem-data", "key": "key-data"}) + assert.NoError(t, err) +} + +func TestGetObject_RequiresPathKey(t *testing.T) { + t.Parallel() + + client := &Client{path: DefaultSecretPath} + + _, err := client.GetObject("certs") + assert.Error(t, err) + assert.True(t, errors.Is(err, ErrInvalidPathKey), "expected ErrInvalidPathKey, got %v", err) + assert.Contains(t, err.Error(), "must contain '/'") +} + +func TestSetObject_RequiresPathKey(t *testing.T) { + t.Parallel() + + client := &Client{path: DefaultSecretPath} + + err := client.SetObject("certs", map[string]string{"cert": "pem-data"}) + assert.Error(t, err) + assert.True(t, errors.Is(err, ErrInvalidPathKey), "expected ErrInvalidPathKey, got %v", err) + assert.Contains(t, err.Error(), "must contain '/'") }