- 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.
232 lines
7.9 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|