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) } } }