mirror of
https://github.com/hanzoai/kms.git
synced 2026-10-17 11:18:16 +00:00
A caller has to tell "this tenant brought no key" from "the store could not answer": the first says fall through to another custody, the second says stop. Both arrived as untyped errors, so the only way to ask was to read the text — and the non-200 branch echoes the response body, so any fault whose body happened to say "not found" was read as an absent secret and sent the caller somewhere less guarded. ErrSecretNotFound was already declared, with a comment promising callers would branch via errors.Is. Wrap it on the three paths that mean a secret is genuinely absent. A tenant miss keeps its own untyped error: it is a fault, not an absence, and reporting it as one would restore the bug in a new place. Co-authored-by: Hanzo Dev <[email protected]>
504 lines
15 KiB
Go
504 lines
15 KiB
Go
package kmsclient
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
kms "github.com/hanzoai/kms/sdk/go"
|
|
)
|
|
|
|
func TestNew_Validation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
cfg Config
|
|
}{
|
|
{"no endpoint", Config{IAMEndpoint: "x", ClientID: "x", ClientSecret: "x", Org: "x"}},
|
|
{"no iam", Config{Endpoint: "x", ClientID: "x", ClientSecret: "x", Org: "x"}},
|
|
{"no client id", Config{Endpoint: "x", IAMEndpoint: "x", ClientSecret: "x", Org: "x"}},
|
|
{"no client secret", Config{Endpoint: "x", IAMEndpoint: "x", ClientID: "x", Org: "x"}},
|
|
{"no org", Config{Endpoint: "x", IAMEndpoint: "x", ClientID: "x", ClientSecret: "x"}},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
_, err := New(tt.cfg)
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNew_Valid(t *testing.T) {
|
|
c, err := New(Config{
|
|
Endpoint: "http://kms:8443",
|
|
IAMEndpoint: "http://iam:8000",
|
|
ClientID: "id",
|
|
ClientSecret: "sec",
|
|
Org: "test-org",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if c.org != "test-org" {
|
|
t.Errorf("org = %q, want test-org", c.org)
|
|
}
|
|
}
|
|
|
|
func TestSplitPathName(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
wantPath string
|
|
wantName string
|
|
}{
|
|
{"providers/alpaca/dev/api_key", "providers/alpaca/dev", "api_key"},
|
|
{"simple/key", "simple", "key"},
|
|
{"key", "", "key"},
|
|
{"a/b/c/d", "a/b/c", "d"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
pn := splitPathName(tt.input)
|
|
if pn.path != tt.wantPath {
|
|
t.Errorf("path = %q, want %q", pn.path, tt.wantPath)
|
|
}
|
|
if pn.name != tt.wantName {
|
|
t.Errorf("name = %q, want %q", pn.name, tt.wantName)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// mockIAM returns a test IAM that always mints the same bearer token.
|
|
func mockIAM(t *testing.T, token string) *httptest.Server {
|
|
t.Helper()
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"access_token": token,
|
|
"expires_in": 3600,
|
|
})
|
|
}))
|
|
}
|
|
|
|
func TestGet_WithMockServer(t *testing.T) {
|
|
iam := mockIAM(t, "test-token-123")
|
|
defer iam.Close()
|
|
|
|
// Server: implements canonical GET /v1/kms/secrets/{path}/{name}. The org is
|
|
// not a segment — the server reads it from the credential.
|
|
const wantPath = "/v1/kms/secrets/providers/alpaca/dev/api_key"
|
|
kms := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get("Authorization") != "Bearer test-token-123" {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if r.Method == http.MethodGet && r.URL.Path == wantPath {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"secret": map[string]any{"value": "CKEJOEAIF2RS6KLVJUSXVOPKLW"},
|
|
})
|
|
return
|
|
}
|
|
http.Error(w, "not found: "+r.Method+" "+r.URL.Path, http.StatusNotFound)
|
|
}))
|
|
defer kms.Close()
|
|
|
|
c, err := New(Config{
|
|
Endpoint: kms.URL,
|
|
IAMEndpoint: iam.URL,
|
|
ClientID: "test-id",
|
|
ClientSecret: "test-secret",
|
|
Org: "hanzo",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
val, err := c.Get(context.Background(), "providers/alpaca/dev", "api_key")
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if val != "CKEJOEAIF2RS6KLVJUSXVOPKLW" {
|
|
t.Errorf("value = %q, want CKEJOEAIF2RS6KLVJUSXVOPKLW", val)
|
|
}
|
|
}
|
|
|
|
func TestGet_FlatValueShape(t *testing.T) {
|
|
iam := mockIAM(t, "tok")
|
|
defer iam.Close()
|
|
kms := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"value": "bare"})
|
|
}))
|
|
defer kms.Close()
|
|
c, _ := New(Config{Endpoint: kms.URL, IAMEndpoint: iam.URL, ClientID: "i", ClientSecret: "s", Org: "org"})
|
|
v, err := c.Get(context.Background(), "a", "b")
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if v != "bare" {
|
|
t.Errorf("value = %q, want bare", v)
|
|
}
|
|
}
|
|
|
|
func TestGet_NotFound(t *testing.T) {
|
|
iam := mockIAM(t, "tok")
|
|
defer iam.Close()
|
|
|
|
kmsSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
}))
|
|
defer kmsSrv.Close()
|
|
|
|
c, _ := New(Config{
|
|
Endpoint: kmsSrv.URL,
|
|
IAMEndpoint: iam.URL,
|
|
ClientID: "id",
|
|
ClientSecret: "sec",
|
|
Org: "org",
|
|
})
|
|
|
|
_, err := c.Get(context.Background(), "no/such", "secret")
|
|
if err == nil {
|
|
t.Fatal("expected error for missing secret")
|
|
}
|
|
if !errors.Is(err, kms.ErrSecretNotFound) {
|
|
t.Errorf("error = %q, want errors.Is(err, kms.ErrSecretNotFound)", err)
|
|
}
|
|
}
|
|
|
|
// A store that answers but cannot serve is not an absent secret. The two carry
|
|
// opposite instructions — fall through to another custody, or stop — so a
|
|
// caller must be able to tell them apart by identity rather than by wording.
|
|
// The body is echoed into the error, so a fault whose text happens to say "not
|
|
// found" is the case that used to be misread.
|
|
func TestGet_StoreFaultIsNotAbsence(t *testing.T) {
|
|
iam := mockIAM(t, "tok")
|
|
defer iam.Close()
|
|
|
|
kmsSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
http.Error(w, `{"error":"upstream tenant not found"}`, http.StatusInternalServerError)
|
|
}))
|
|
defer kmsSrv.Close()
|
|
|
|
c, _ := New(Config{
|
|
Endpoint: kmsSrv.URL,
|
|
IAMEndpoint: iam.URL,
|
|
ClientID: "id",
|
|
ClientSecret: "sec",
|
|
Org: "org",
|
|
})
|
|
|
|
_, err := c.Get(context.Background(), "some/path", "secret")
|
|
if err == nil {
|
|
t.Fatal("expected error for a store fault")
|
|
}
|
|
if errors.Is(err, kms.ErrSecretNotFound) {
|
|
t.Errorf("a 500 was classified as an absent secret: %q", err)
|
|
}
|
|
}
|
|
|
|
func TestGet_TokenCaching(t *testing.T) {
|
|
tokenCalls := 0
|
|
iam := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
tokenCalls++
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"access_token": "cached-token",
|
|
"expires_in": 3600,
|
|
})
|
|
}))
|
|
defer iam.Close()
|
|
|
|
kms := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"secret": map[string]any{"value": "v"}})
|
|
}))
|
|
defer kms.Close()
|
|
|
|
c, _ := New(Config{
|
|
Endpoint: kms.URL,
|
|
IAMEndpoint: iam.URL,
|
|
ClientID: "id",
|
|
ClientSecret: "sec",
|
|
Org: "org",
|
|
})
|
|
|
|
ctx := context.Background()
|
|
_, _ = c.Get(ctx, "a", "b")
|
|
_, _ = c.Get(ctx, "a", "b")
|
|
_, _ = c.Get(ctx, "a", "b")
|
|
|
|
if tokenCalls != 1 {
|
|
t.Errorf("expected 1 token call (cached), got %d", tokenCalls)
|
|
}
|
|
}
|
|
|
|
func TestPut_SendsCanonicalPayload(t *testing.T) {
|
|
iam := mockIAM(t, "tok")
|
|
defer iam.Close()
|
|
|
|
var gotPath string
|
|
var gotBody map[string]string
|
|
kms := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.Path
|
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
|
w.WriteHeader(http.StatusCreated)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
|
|
}))
|
|
defer kms.Close()
|
|
|
|
c, _ := New(Config{Endpoint: kms.URL, IAMEndpoint: iam.URL, ClientID: "i", ClientSecret: "s", Org: "hanzo"})
|
|
if err := c.Put(context.Background(), "providers/square/dev", "access_token", "sq_abc"); err != nil {
|
|
t.Fatalf("Put: %v", err)
|
|
}
|
|
if gotPath != "/v1/kms/secrets" {
|
|
t.Errorf("path = %q, want /v1/kms/secrets", gotPath)
|
|
}
|
|
if gotBody["path"] != "providers/square/dev" || gotBody["name"] != "access_token" || gotBody["value"] != "sq_abc" {
|
|
t.Errorf("body = %+v, want path=providers/square/dev name=access_token value=sq_abc", gotBody)
|
|
}
|
|
// env MUST be on the write body — the server now rejects omitted env. With
|
|
// no Config.Env set it defaults to "default" and is sent explicitly.
|
|
if gotBody["env"] != "default" {
|
|
t.Errorf("body env = %q, want default (sent explicitly, never omitted)", gotBody["env"])
|
|
}
|
|
}
|
|
|
|
// A client configured for a specific env sends it on the write body, so the
|
|
// write lands in that env's record rather than the server's legacy default.
|
|
func TestPut_SendsConfiguredEnv(t *testing.T) {
|
|
iam := mockIAM(t, "tok")
|
|
defer iam.Close()
|
|
|
|
var gotBody map[string]string
|
|
kms := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
|
w.WriteHeader(http.StatusCreated)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
|
|
}))
|
|
defer kms.Close()
|
|
|
|
c, _ := New(Config{Endpoint: kms.URL, IAMEndpoint: iam.URL, ClientID: "i", ClientSecret: "s", Org: "hanzo", Env: "prod"})
|
|
if err := c.Put(context.Background(), "iam-passwords", "Z_PASSWORD", "s3cr3t"); err != nil {
|
|
t.Fatalf("Put: %v", err)
|
|
}
|
|
if gotBody["env"] != "prod" {
|
|
t.Errorf("body env = %q, want prod", gotBody["env"])
|
|
}
|
|
}
|
|
|
|
// Reads and deletes carry ?env= too, so they target the configured env
|
|
// instead of relying on any server default.
|
|
func TestGetDelete_SendEnvQuery(t *testing.T) {
|
|
iam := mockIAM(t, "tok")
|
|
defer iam.Close()
|
|
|
|
var gotQuery, gotMethod string
|
|
kms := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotQuery = r.URL.RawQuery
|
|
gotMethod = r.Method
|
|
if r.Method == http.MethodGet {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"secret": map[string]any{"value": "v"}})
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
defer kms.Close()
|
|
|
|
c, _ := New(Config{Endpoint: kms.URL, IAMEndpoint: iam.URL, ClientID: "i", ClientSecret: "s", Org: "hanzo", Env: "prod"})
|
|
|
|
if _, err := c.Get(context.Background(), "iam-passwords", "Z_PASSWORD"); err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if gotMethod != http.MethodGet || gotQuery != "env=prod" {
|
|
t.Errorf("GET query = %q (method %s), want env=prod", gotQuery, gotMethod)
|
|
}
|
|
|
|
if err := c.Delete(context.Background(), "iam-passwords", "Z_PASSWORD"); err != nil {
|
|
t.Fatalf("Delete: %v", err)
|
|
}
|
|
if gotMethod != http.MethodDelete || gotQuery != "env=prod" {
|
|
t.Errorf("DELETE query = %q (method %s), want env=prod", gotQuery, gotMethod)
|
|
}
|
|
}
|
|
|
|
func TestList_UsesCanonicalPath(t *testing.T) {
|
|
iam := mockIAM(t, "tok")
|
|
defer iam.Close()
|
|
|
|
var gotPath, gotQuery string
|
|
kms := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.Path
|
|
gotQuery = r.URL.RawQuery
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"items": []map[string]any{
|
|
{"path": "providers/square/dev", "name": "access_token"},
|
|
{"path": "providers/alpaca/dev", "name": "api_key"},
|
|
},
|
|
})
|
|
}))
|
|
defer kms.Close()
|
|
|
|
c, _ := New(Config{Endpoint: kms.URL, IAMEndpoint: iam.URL, ClientID: "i", ClientSecret: "s", Org: "hanzo"})
|
|
got, err := c.List(context.Background(), "providers/square")
|
|
if err != nil {
|
|
t.Fatalf("List: %v", err)
|
|
}
|
|
if gotPath != "/v1/kms/secrets" {
|
|
t.Errorf("path = %q, want /v1/kms/secrets", gotPath)
|
|
}
|
|
if gotQuery != "prefix=providers%2Fsquare" {
|
|
t.Errorf("query = %q, want prefix=providers%%2Fsquare", gotQuery)
|
|
}
|
|
// Client-side filter must drop the alpaca entry even if the server returned it.
|
|
if len(got) != 1 || got[0] != "providers/square/dev/access_token" {
|
|
t.Errorf("items = %v, want [providers/square/dev/access_token]", got)
|
|
}
|
|
}
|
|
|
|
func TestDelete_UsesCanonicalPath(t *testing.T) {
|
|
iam := mockIAM(t, "tok")
|
|
defer iam.Close()
|
|
var gotPath, gotMethod string
|
|
kms := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.Path
|
|
gotMethod = r.Method
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
defer kms.Close()
|
|
c, _ := New(Config{Endpoint: kms.URL, IAMEndpoint: iam.URL, ClientID: "i", ClientSecret: "s", Org: "hanzo"})
|
|
if err := c.Delete(context.Background(), "providers/square/dev", "access_token"); err != nil {
|
|
t.Fatalf("Delete: %v", err)
|
|
}
|
|
if gotMethod != http.MethodDelete {
|
|
t.Errorf("method = %q, want DELETE", gotMethod)
|
|
}
|
|
if gotPath != "/v1/kms/secrets/providers/square/dev/access_token" {
|
|
t.Errorf("path = %q, want the canonical secrets path", gotPath)
|
|
}
|
|
}
|
|
|
|
func TestSecretPath_EscapesSegments(t *testing.T) {
|
|
c := &Client{endpoint: "http://kms:8443", org: "liq/uid"}
|
|
got := c.secretPath("foo bar/baz", "k+q")
|
|
// Segments individually escaped; "/" preserved as separator. Org escaped once.
|
|
want := "http://kms:8443/v1/kms/secrets/foo%20bar/baz/k+q"
|
|
if got != want {
|
|
t.Errorf("secretPath = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestFetchEnv(t *testing.T) {
|
|
iam := mockIAM(t, "tok")
|
|
defer iam.Close()
|
|
|
|
secrets := map[string]string{
|
|
"providers/alpaca/dev/api_key": "KEY123",
|
|
"providers/alpaca/dev/api_secret": "SEC456",
|
|
}
|
|
|
|
kms := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Path: /v1/kms/secrets/<path>/<name>
|
|
const prefix = "/v1/kms/secrets/"
|
|
if !strings.HasPrefix(r.URL.Path, prefix) {
|
|
http.Error(w, "nf", http.StatusNotFound)
|
|
return
|
|
}
|
|
rest := r.URL.Path[len(prefix):]
|
|
if v, ok := secrets[rest]; ok {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"secret": map[string]any{"value": v}})
|
|
return
|
|
}
|
|
http.Error(w, "nf", http.StatusNotFound)
|
|
}))
|
|
defer kms.Close()
|
|
|
|
// Override env helpers to avoid polluting real env.
|
|
envStore := map[string]string{}
|
|
lookupEnv = func(key string) (string, bool) {
|
|
v, ok := envStore[key]
|
|
return v, ok
|
|
}
|
|
setEnv = func(key, val string) error {
|
|
envStore[key] = val
|
|
return nil
|
|
}
|
|
defer func() {
|
|
lookupEnv = defaultLookupEnv
|
|
setEnv = defaultSetEnv
|
|
}()
|
|
|
|
c, _ := New(Config{
|
|
Endpoint: kms.URL,
|
|
IAMEndpoint: iam.URL,
|
|
ClientID: "id",
|
|
ClientSecret: "sec",
|
|
Org: "org",
|
|
})
|
|
|
|
n, err := c.FetchEnv(context.Background(), map[string]string{
|
|
"BROKER_API_KEY": "providers/alpaca/dev/api_key",
|
|
"BROKER_API_SECRET": "providers/alpaca/dev/api_secret",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("FetchEnv: %v", err)
|
|
}
|
|
if n != 2 {
|
|
t.Errorf("fetched %d, want 2", n)
|
|
}
|
|
if envStore["BROKER_API_KEY"] != "KEY123" {
|
|
t.Errorf("BROKER_API_KEY = %q, want KEY123", envStore["BROKER_API_KEY"])
|
|
}
|
|
if envStore["BROKER_API_SECRET"] != "SEC456" {
|
|
t.Errorf("BROKER_API_SECRET = %q, want SEC456", envStore["BROKER_API_SECRET"])
|
|
}
|
|
}
|
|
|
|
func TestFetchEnv_NoOverrideExisting(t *testing.T) {
|
|
iam := mockIAM(t, "tok")
|
|
defer iam.Close()
|
|
|
|
kms := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
// Should never be called; existing env short-circuits before network.
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"secret": map[string]any{"value": "from-kms"}})
|
|
}))
|
|
defer kms.Close()
|
|
|
|
envStore := map[string]string{
|
|
"EXISTING_KEY": "from-env",
|
|
}
|
|
lookupEnv = func(key string) (string, bool) {
|
|
v, ok := envStore[key]
|
|
return v, ok
|
|
}
|
|
setEnv = func(key, val string) error {
|
|
envStore[key] = val
|
|
return nil
|
|
}
|
|
defer func() {
|
|
lookupEnv = defaultLookupEnv
|
|
setEnv = defaultSetEnv
|
|
}()
|
|
|
|
c, _ := New(Config{
|
|
Endpoint: kms.URL,
|
|
IAMEndpoint: iam.URL,
|
|
ClientID: "id",
|
|
ClientSecret: "sec",
|
|
Org: "org",
|
|
})
|
|
|
|
_, _ = c.FetchEnv(context.Background(), map[string]string{
|
|
"EXISTING_KEY": "some/path/key",
|
|
})
|
|
|
|
if envStore["EXISTING_KEY"] != "from-env" {
|
|
t.Errorf("expected existing env to not be overridden, got %q", envStore["EXISTING_KEY"])
|
|
}
|
|
}
|
|
|
|
// Save defaults so we can restore after test.
|
|
var defaultLookupEnv = lookupEnv
|
|
var defaultSetEnv = setEnv
|