Files
vault-tui/internal/vault/kv_test.go
T
f.weber ae30ba1240 feat(vault-tui): implement KV client and service for managing secrets
- Added internal/vault/client.go for creating a Vault client with configuration settings.
- Introduced internal/vault/errors.go to classify Vault API errors for better UI handling.
- Created internal/vault/kv.go to manage KV secrets, including listing, reading, writing, and deleting operations.
- Implemented internal/vault/mounts.go to list and describe secret engine mounts.
- Developed internal/vault/service.go to provide a unified entry point for Vault operations.
- Added internal/vault/kv_test.go for comprehensive testing of KV operations.
- Introduced internal/ui/toast.go for transient notifications in the UI.
- Added renovate.json for dependency management and updates.
2026-08-14 11:09:03 +02:00

232 lines
7.9 KiB
Go

package vault
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/hashicorp/vault/api"
)
// newTestClient builds a real *api.Client pointed at srv, so these tests
// exercise the exact path construction KV sends over the wire — this is
// the single most bug-prone part of any Vault client (see kv.go's doc
// comment), so it gets exhaustive request-shape assertions rather than
// mocking KV itself.
func newTestClient(t *testing.T, srv *httptest.Server) *api.Client {
t.Helper()
cfg := api.DefaultConfig()
cfg.Address = srv.URL
c, err := api.NewClient(cfg)
if err != nil {
t.Fatalf("api.NewClient: %v", err)
}
c.SetToken("test-token")
return c
}
func jsonBody(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func TestKV_List_PathMapping(t *testing.T) {
cases := []struct {
name string
mount Mount
dir string
wantPath string
}{
{"kv2 root", Mount{Path: "secret/", Kind: EngineKVv2}, "", "/v1/secret/metadata"},
{"kv2 nested", Mount{Path: "secret/", Kind: EngineKVv2}, "team/prod", "/v1/secret/metadata/team/prod"},
{"kv1 root", Mount{Path: "kv1/", Kind: EngineKVv1}, "", "/v1/kv1"},
{"kv1 nested", Mount{Path: "kv1/", Kind: EngineKVv1}, "team", "/v1/kv1/team"},
{"cubbyhole", Mount{Path: "cubbyhole/", Kind: EngineKVv1}, "", "/v1/cubbyhole"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var gotPath, gotMethod string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath, gotMethod = r.URL.Path, r.Method
jsonBody(w, map[string]interface{}{"data": map[string]interface{}{"keys": []string{"a", "b/"}}})
}))
defer srv.Close()
kv := &KV{Client: newTestClient(t, srv)}
_, err := kv.List(context.Background(), c.mount, c.dir)
if err != nil {
t.Fatalf("List: %v", err)
}
if gotMethod != "LIST" && gotMethod != "GET" {
t.Errorf("method = %q, want LIST or GET", gotMethod)
}
if gotPath != c.wantPath {
t.Errorf("path = %q, want %q", gotPath, c.wantPath)
}
})
}
}
func TestKV_List_SplitsDirsAndLeaves(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
jsonBody(w, map[string]interface{}{"data": map[string]interface{}{"keys": []string{"leaf1", "dir1/", "leaf2", "dir2/"}}})
}))
defer srv.Close()
kv := &KV{Client: newTestClient(t, srv)}
l, err := kv.List(context.Background(), Mount{Path: "secret/", Kind: EngineKVv2}, "")
if err != nil {
t.Fatalf("List: %v", err)
}
if len(l.Dirs) != 2 || len(l.Leaves) != 2 {
t.Fatalf("got %d dirs, %d leaves; want 2/2 (dirs=%v leaves=%v)", len(l.Dirs), len(l.Leaves), l.Dirs, l.Leaves)
}
}
func TestKV_Read_PathMapping(t *testing.T) {
cases := []struct {
name string
mount Mount
path string
version int
wantPath string
wantQS string
}{
{"kv2 latest", Mount{Path: "secret/", Kind: EngineKVv2}, "team/db", 0, "/v1/secret/data/team/db", ""},
{"kv2 versioned", Mount{Path: "secret/", Kind: EngineKVv2}, "team/db", 3, "/v1/secret/data/team/db", "version=3"},
{"kv1", Mount{Path: "kv1/", Kind: EngineKVv1}, "team/db", 0, "/v1/kv1/team/db", ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var gotPath, gotQuery string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath, gotQuery = r.URL.Path, r.URL.RawQuery
if c.mount.Kind == EngineKVv2 {
jsonBody(w, map[string]interface{}{"data": map[string]interface{}{
"data": map[string]interface{}{"k": "v"},
"metadata": map[string]interface{}{"version": 1, "created_time": "now"},
}})
} else {
jsonBody(w, map[string]interface{}{"data": map[string]interface{}{"k": "v"}})
}
}))
defer srv.Close()
kv := &KV{Client: newTestClient(t, srv)}
_, err := kv.Read(context.Background(), c.mount, c.path, c.version)
if err != nil {
t.Fatalf("Read: %v", err)
}
if gotPath != c.wantPath {
t.Errorf("path = %q, want %q", gotPath, c.wantPath)
}
if c.wantQS != "" && gotQuery != c.wantQS {
t.Errorf("query = %q, want %q", gotQuery, c.wantQS)
}
})
}
}
func TestKV_Write_PathAndCAS(t *testing.T) {
var gotPath string
var gotBody map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
_ = json.NewDecoder(r.Body).Decode(&gotBody)
jsonBody(w, map[string]interface{}{"data": map[string]interface{}{"version": 2}})
}))
defer srv.Close()
kv := &KV{Client: newTestClient(t, srv)}
ack, err := kv.Write(context.Background(), Mount{Path: "secret/", Kind: EngineKVv2}, "team/db",
map[string]interface{}{"k": "v"}, true, 1)
if err != nil {
t.Fatalf("Write: %v", err)
}
if gotPath != "/v1/secret/data/team/db" {
t.Errorf("path = %q, want /v1/secret/data/team/db", gotPath)
}
opts, ok := gotBody["options"].(map[string]interface{})
if !ok {
t.Fatalf("request body missing options (cas): %v", gotBody)
}
if cas, _ := opts["cas"].(float64); cas != 1 {
t.Errorf("cas = %v, want 1", opts["cas"])
}
if ack.Version != 2 {
t.Errorf("ack.Version = %d, want 2 (json.Number decoding regression check)", ack.Version)
}
}
func TestKV_ReadOnly_RefusesWrites(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Errorf("server should never be called in read-only mode; got %s %s", r.Method, r.URL.Path)
}))
defer srv.Close()
kv := &KV{Client: newTestClient(t, srv), ReadOnly: true}
if _, err := kv.Write(context.Background(), Mount{Path: "secret/", Kind: EngineKVv2}, "x", nil, false, 0); err != ErrReadOnly {
t.Errorf("Write err = %v, want ErrReadOnly", err)
}
if _, err := kv.Delete(context.Background(), Mount{Path: "secret/", Kind: EngineKVv2}, "x", OpSoftDelete, nil); err != ErrReadOnly {
t.Errorf("Delete err = %v, want ErrReadOnly", err)
}
}
func TestKV_Delete_OpPaths(t *testing.T) {
cases := []struct {
name string
op DeleteOp
versions []int
wantPath string
wantMeth string
}{
// Soft-deleting the current version (no explicit versions) has no
// shorthand on the "delete" endpoint, so it goes through the plain
// data endpoint instead — the same request "vault kv delete"
// (without -versions) makes.
{"soft delete current version", OpSoftDelete, nil, "/v1/secret/data/x", "DELETE"},
{"soft delete specific version", OpSoftDelete, []int{2}, "/v1/secret/delete/x", "PUT"},
{"undelete", OpUndelete, []int{2}, "/v1/secret/undelete/x", "PUT"},
{"destroy", OpDestroy, []int{2}, "/v1/secret/destroy/x", "PUT"},
{"delete metadata", OpDeleteMetadata, nil, "/v1/secret/metadata/x", "DELETE"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var gotPath, gotMethod string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath, gotMethod = r.URL.Path, r.Method
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
kv := &KV{Client: newTestClient(t, srv)}
if _, err := kv.Delete(context.Background(), Mount{Path: "secret/", Kind: EngineKVv2}, "x", c.op, c.versions); err != nil {
t.Fatalf("Delete: %v", err)
}
if gotPath != c.wantPath {
t.Errorf("path = %q, want %q", gotPath, c.wantPath)
}
if gotMethod != c.wantMeth {
t.Errorf("method = %q, want %q", gotMethod, c.wantMeth)
}
})
}
}
func TestKV_Delete_RequiresVersionsForUndeleteAndDestroy(t *testing.T) {
for _, op := range []DeleteOp{OpUndelete, OpDestroy} {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Errorf("server should never be called without explicit versions; got %s %s", r.Method, r.URL.Path)
}))
defer srv.Close()
kv := &KV{Client: newTestClient(t, srv)}
if _, err := kv.Delete(context.Background(), Mount{Path: "secret/", Kind: EngineKVv2}, "x", op, nil); err == nil {
t.Errorf("Delete(%v, nil versions): want error, got nil", op)
}
}
}