mirror of
https://github.com/hanzoai/notify.git
synced 2026-10-30 15:04:32 +00:00
- DefaultBrand const flipped from 'liquidity' to 'hanzo' (plivo_resolver.go, chain.go and all doc comments) - Replace <vendor> unsubscribe / mailto hostnames with hanzo.ai in listunsubscribe.go and all marketing tests (raw_test, category_test, marketing_test, etc.) - Rename seed<tenant> test fixture → seedDefault and switch its tenant literals from 'liquidity' to use DefaultBrand - Replace 'liquidity' tenant literals in chain_test / chain_marketing_test / preferences_test / router_test / kmsbridge client_test with 'acme' (generic test tenant) — tests still cover the brand→default fallback semantics - Drop github.com/<tenant>/* from GOPRIVATE in Dockerfile - Drop <tenant>/operator + '<tenant>/bd#105' references from k8s/kustomization.yaml + pkg/types/types.go comments - Replace 'LiquidReplicate sidecar' with generic 'replicator sidecar' in schema.go consent-log retention comment - Rewrite LLM.md fleet-default KMS path examples to use brand/hanzo/* Tests pass: tenant, marketing, preferences, kmsbridge, router.
298 lines
10 KiB
Go
298 lines
10 KiB
Go
// Tests for kmsbridge.Client. The bridge has two outbound dialogs (IAM
|
|
// token exchange + KMS secret read/write/delete); we drive both with
|
|
// httptest.NewServer mocks and assert on the URL shape, headers, and
|
|
// caching behaviour. No external services touched.
|
|
//
|
|
// These tests lock the canonical URL shapes that the deployed
|
|
// luxfi/kms and hanzoai/iam serve, so a future refactor cannot
|
|
// silently regress.
|
|
package kmsbridge
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestGetSecret_CanonicalURL asserts the bridge hits the
|
|
// /v1/kms/orgs/{org}/secrets/{path}/{name} route and parses both wire
|
|
// shapes the server returns.
|
|
func TestGetSecret_CanonicalURL(t *testing.T) {
|
|
var gotPath atomic.Value
|
|
var gotAuth atomic.Value
|
|
|
|
iam := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/iam/oauth/access_token" {
|
|
t.Errorf("iam: unexpected path %s", r.URL.Path)
|
|
http.Error(w, "wrong path", 404)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"access_token":"test-jwt","expires_in":3600}`))
|
|
}))
|
|
defer iam.Close()
|
|
|
|
kms := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath.Store(r.URL.Path)
|
|
gotAuth.Store(r.Header.Get("Authorization"))
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"secret":{"value":"plivo-auth-id-123"}}`))
|
|
}))
|
|
defer kms.Close()
|
|
|
|
c, err := New(Config{
|
|
KMSEndpoint: kms.URL,
|
|
IAMEndpoint: iam.URL,
|
|
ClientID: "notify",
|
|
ClientSecret: "secret",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("new: %v", err)
|
|
}
|
|
|
|
val, err := c.GetSecret("acme", "brand/acme/plivo/auth-id")
|
|
if err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
if val != "plivo-auth-id-123" {
|
|
t.Errorf("got value %q, want plivo-auth-id-123", val)
|
|
}
|
|
if got := gotPath.Load().(string); got != "/v1/kms/orgs/acme/secrets/brand/acme/plivo/auth-id" {
|
|
t.Errorf("kms URL: got %q, want canonical /v1/kms/orgs/acme/secrets/brand/acme/plivo/auth-id", got)
|
|
}
|
|
if got := gotAuth.Load().(string); got != "Bearer test-jwt" {
|
|
t.Errorf("kms auth: got %q, want Bearer test-jwt", got)
|
|
}
|
|
}
|
|
|
|
// TestGetSecret_FlatValueShape covers the older wire shape (no
|
|
// "secret" wrapper) so the bridge stays compatible with both.
|
|
func TestGetSecret_FlatValueShape(t *testing.T) {
|
|
iam := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(`{"access_token":"jwt","expires_in":3600}`))
|
|
}))
|
|
defer iam.Close()
|
|
kms := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(`{"value":"flat-value"}`))
|
|
}))
|
|
defer kms.Close()
|
|
|
|
c, err := New(Config{KMSEndpoint: kms.URL, IAMEndpoint: iam.URL, ClientID: "x", ClientSecret: "y"})
|
|
if err != nil {
|
|
t.Fatalf("new: %v", err)
|
|
}
|
|
got, err := c.GetSecret("org", "p/n")
|
|
if err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
if got != "flat-value" {
|
|
t.Errorf("got %q, want flat-value", got)
|
|
}
|
|
}
|
|
|
|
// TestGetSecret_Cache asserts hot reads do not redial KMS.
|
|
func TestGetSecret_Cache(t *testing.T) {
|
|
var hits int32
|
|
iam := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(`{"access_token":"jwt","expires_in":3600}`))
|
|
}))
|
|
defer iam.Close()
|
|
kms := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
atomic.AddInt32(&hits, 1)
|
|
_, _ = w.Write([]byte(`{"value":"cached"}`))
|
|
}))
|
|
defer kms.Close()
|
|
|
|
c, _ := New(Config{KMSEndpoint: kms.URL, IAMEndpoint: iam.URL, ClientID: "x", ClientSecret: "y"})
|
|
for i := 0; i < 5; i++ {
|
|
if _, err := c.GetSecret("org", "p/n"); err != nil {
|
|
t.Fatalf("get %d: %v", i, err)
|
|
}
|
|
}
|
|
if got := atomic.LoadInt32(&hits); got != 1 {
|
|
t.Errorf("kms hits: got %d, want 1 (cache miss + 4 cache hits)", got)
|
|
}
|
|
}
|
|
|
|
// TestSetSecret_UpsertBody verifies the POST body shape — KMS needs
|
|
// {path, name, value} so it can split the upsert by (path, name).
|
|
func TestSetSecret_UpsertBody(t *testing.T) {
|
|
var gotBody atomic.Value
|
|
iam := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(`{"access_token":"jwt","expires_in":3600}`))
|
|
}))
|
|
defer iam.Close()
|
|
kms := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/kms/orgs/acme/secrets" {
|
|
t.Errorf("kms put path: got %q, want /v1/kms/orgs/acme/secrets", r.URL.Path)
|
|
}
|
|
b, _ := io.ReadAll(r.Body)
|
|
gotBody.Store(string(b))
|
|
w.WriteHeader(http.StatusCreated)
|
|
}))
|
|
defer kms.Close()
|
|
|
|
c, _ := New(Config{KMSEndpoint: kms.URL, IAMEndpoint: iam.URL, ClientID: "x", ClientSecret: "y"})
|
|
if err := c.SetSecret("acme", "brand/acme/plivo/auth-id", "ABCDEF"); err != nil {
|
|
t.Fatalf("set: %v", err)
|
|
}
|
|
var got struct {
|
|
Path string `json:"path"`
|
|
Name string `json:"name"`
|
|
Value string `json:"value"`
|
|
}
|
|
if err := json.Unmarshal([]byte(gotBody.Load().(string)), &got); err != nil {
|
|
t.Fatalf("decode body: %v", err)
|
|
}
|
|
if got.Path != "brand/acme/plivo" || got.Name != "auth-id" || got.Value != "ABCDEF" {
|
|
t.Errorf("upsert body wrong: %+v", got)
|
|
}
|
|
}
|
|
|
|
// TestDeleteSecret_404IsSuccess locks the idempotent-delete contract.
|
|
func TestDeleteSecret_404IsSuccess(t *testing.T) {
|
|
iam := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(`{"access_token":"jwt","expires_in":3600}`))
|
|
}))
|
|
defer iam.Close()
|
|
kms := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer kms.Close()
|
|
c, _ := New(Config{KMSEndpoint: kms.URL, IAMEndpoint: iam.URL, ClientID: "x", ClientSecret: "y"})
|
|
if err := c.DeleteSecret("org", "p/n"); err != nil {
|
|
t.Fatalf("delete: got %v, want nil (404 is idempotent success)", err)
|
|
}
|
|
}
|
|
|
|
// TestInvalidateCache drops cached values so the next read redials.
|
|
func TestInvalidateCache(t *testing.T) {
|
|
var hits int32
|
|
iam := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(`{"access_token":"jwt","expires_in":3600}`))
|
|
}))
|
|
defer iam.Close()
|
|
kms := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
atomic.AddInt32(&hits, 1)
|
|
_, _ = w.Write([]byte(`{"value":"v"}`))
|
|
}))
|
|
defer kms.Close()
|
|
c, _ := New(Config{KMSEndpoint: kms.URL, IAMEndpoint: iam.URL, ClientID: "x", ClientSecret: "y"})
|
|
_, _ = c.GetSecret("org", "p/n")
|
|
c.InvalidateCache("org")
|
|
_, _ = c.GetSecret("org", "p/n")
|
|
if got := atomic.LoadInt32(&hits); got != 2 {
|
|
t.Errorf("kms hits: got %d, want 2 (no cache after invalidate)", got)
|
|
}
|
|
}
|
|
|
|
// TestStaticBearer skips IAM exchange when the override is set.
|
|
func TestStaticBearer(t *testing.T) {
|
|
var iamCalls int32
|
|
iam := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
|
atomic.AddInt32(&iamCalls, 1)
|
|
}))
|
|
defer iam.Close()
|
|
|
|
var gotAuth atomic.Value
|
|
kms := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotAuth.Store(r.Header.Get("Authorization"))
|
|
_, _ = w.Write([]byte(`{"value":"v"}`))
|
|
}))
|
|
defer kms.Close()
|
|
|
|
c, err := New(Config{KMSEndpoint: kms.URL, StaticBearer: "fixed-token"})
|
|
if err != nil {
|
|
t.Fatalf("new: %v", err)
|
|
}
|
|
if _, err := c.GetSecret("org", "p/n"); err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
if got := atomic.LoadInt32(&iamCalls); got != 0 {
|
|
t.Errorf("iam calls: got %d, want 0 (StaticBearer suppresses exchange)", got)
|
|
}
|
|
if got := gotAuth.Load().(string); got != "Bearer fixed-token" {
|
|
t.Errorf("auth header: got %q, want Bearer fixed-token", got)
|
|
}
|
|
}
|
|
|
|
// TestSecretURL_Escaping covers the URL builder for the boring-but-easy
|
|
// to break case: a path with multiple segments, all of which need to
|
|
// keep their slashes as path separators (not %2F).
|
|
func TestSecretURL_Escaping(t *testing.T) {
|
|
c := &Client{kmsEndpoint: "http://kms.test"}
|
|
got := c.secretURL("acme", "brand/acme/plivo/auth-id")
|
|
want := "http://kms.test/v1/kms/orgs/acme/secrets/brand/acme/plivo/auth-id"
|
|
if got != want {
|
|
t.Errorf("got %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
// TestNew_Validation covers the required-field error cases.
|
|
func TestNew_Validation(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
cfg Config
|
|
want string
|
|
}{
|
|
{"no kms endpoint", Config{IAMEndpoint: "x", ClientID: "x", ClientSecret: "x"}, "KMSEndpoint is required"},
|
|
{"no iam endpoint", Config{KMSEndpoint: "x", ClientID: "x", ClientSecret: "x"}, "IAMEndpoint is required"},
|
|
{"no client id", Config{KMSEndpoint: "x", IAMEndpoint: "x", ClientSecret: "x"}, "ClientID is required"},
|
|
{"no client secret", Config{KMSEndpoint: "x", IAMEndpoint: "x", ClientID: "x"}, "ClientSecret is required"},
|
|
{"static bearer skips iam fields", Config{KMSEndpoint: "x", StaticBearer: "tok"}, ""},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := New(tc.cfg)
|
|
if tc.want == "" {
|
|
if err != nil {
|
|
t.Errorf("got err=%v, want nil", err)
|
|
}
|
|
return
|
|
}
|
|
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
|
t.Errorf("got err=%v, want substring %q", err, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestTokenRefresh_OnExpiry asserts that an expiring token triggers a
|
|
// fresh IAM call. We feed a 1-second token, sleep past it, then expect
|
|
// a second IAM call.
|
|
func TestTokenRefresh_OnExpiry(t *testing.T) {
|
|
var iamCalls int32
|
|
iam := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
atomic.AddInt32(&iamCalls, 1)
|
|
// Below the 60s skew → bridge treats as already-near-expiry,
|
|
// refreshing on every call.
|
|
_, _ = w.Write([]byte(`{"access_token":"jwt","expires_in":1}`))
|
|
}))
|
|
defer iam.Close()
|
|
kms := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(`{"value":"v"}`))
|
|
}))
|
|
defer kms.Close()
|
|
c, _ := New(Config{KMSEndpoint: kms.URL, IAMEndpoint: iam.URL, ClientID: "x", ClientSecret: "y"})
|
|
// Two distinct secret paths to bypass the secret cache; the token
|
|
// cache is what we are testing here.
|
|
if _, err := c.GetSecret("org", "p/n1"); err != nil {
|
|
t.Fatalf("get1: %v", err)
|
|
}
|
|
if _, err := c.GetSecret("org", "p/n2"); err != nil {
|
|
t.Fatalf("get2: %v", err)
|
|
}
|
|
if got := atomic.LoadInt32(&iamCalls); got != 2 {
|
|
t.Errorf("iam calls: got %d, want 2 (expires_in=1 forces refresh on every read)", got)
|
|
}
|
|
}
|
|
|
|
// Compile-time guard: ensure the time import stays used in case we
|
|
// remove a test that touches it.
|
|
var _ = time.Second
|