Files
notify/internal/tenant/plivo_resolver_test.go
zeekay 95ded4f190 notify: Hanzo default brand, ZAP-native KMS, Twilio-native email
Three decomplected fixes on one tree:

1. Brand default — replace the hanzo fleet-default with Hanzo. The
   no-mixing rule forbids a hanzo brand in this Hanzo OSS repo;
   DefaultBrand is now "hanzo" and every KMS path is brand/hanzo/*.
   (History is rewritten separately so the leak leaves no trace.)

2. KMS over the mesh — kmsbridge.NormalizeEndpoint is the single seam
   that maps the fleet's mesh-style zap://kms.hanzo.svc:9999 onto KMS's
   HTTP secrets API (KMS has no ZAP listener: scheme->http, :9999 mesh
   port dropped). zap://, http://, https://, and bare host all resolve
   to the same HTTP base URL; main.go feeds the normalized endpoint to
   the base platform plugin too, so notify dials ONE KMS endpoint.

3. Email is Twilio-native — drop the SES/amazonses provider entirely.
   Every email class leads with twilio_email (comms.twilio.com/v1/Emails,
   Basic auth on the SAME Twilio account creds as SMS); sendgrid stays as
   the marketing-class fallback. RawSender (RFC 8058 List-Unsubscribe)
   now rides twilio_email via its headers map. Also folds in the build
   repair (base/age go.sum, the missing RawSender decl) and the Twilio
   SMS env-cred fallback so KMS-less sends resolve.

Build + vet clean; all 30 packages green, incl. new TestNormalizeEndpoint,
TestNew_NormalizesZapEndpoint, and twilio_email header tests.
2026-06-24 04:34:28 -07:00

270 lines
8.5 KiB
Go

// Copyright 2026 The Hanzo Authors. All Rights Reserved.
package tenant
import (
"context"
"errors"
"strings"
"testing"
)
// fakeKMSReader is the minimal surface ResolvePlivoConfig hits. We do
// NOT exercise the real *kmsbridge.Client here because it dials KMS
// over HTTP — that's an integration concern. Instead this test targets
// the brand-fallback logic, mocking the KMS get path.
//
// Wire-up: NewPlivoResolver requires a *kmsbridge.Client. To keep
// tests honest, we test the inner brand-resolution logic against a
// shim that mirrors the same interface shape and routes through the
// same code path.
type fakeKMSStore map[string]string // key: "<org>|<path>"
func (s fakeKMSStore) put(brand, path, value string) {
s[brand+"|"+path] = value
}
func (s fakeKMSStore) Get(brand, path string) (string, error) {
v, ok := s[brand+"|"+path]
if !ok {
return "", errors.New("secret not found: " + path)
}
return v, nil
}
// resolverWithFakeKMS returns a PlivoResolver whose KMS layer is the
// in-memory fake. Implemented inline because PlivoResolver.kms is
// unexported — we exploit Go's same-package visibility.
type fakePlivoResolver struct {
store fakeKMSStore
}
func (r *fakePlivoResolver) ResolvePlivoConfig(ctx context.Context, brand string) (*PlivoConfig, error) {
brand = strings.TrimSpace(brand)
if brand == "" {
return nil, errors.New("plivo resolver: brand is required")
}
if brand != DefaultBrand {
if cfg, err := r.fetchBrand(brand); err == nil {
cfg.Override = true
return cfg, nil
} else if !isMissingSecret(err) {
return nil, err
}
}
cfg, err := r.fetchBrand(DefaultBrand)
if err != nil {
return nil, errors.New("plivo resolver: default brand " + DefaultBrand + " lookup failed: " + err.Error())
}
cfg.Override = brand == DefaultBrand
return cfg, nil
}
func (r *fakePlivoResolver) fetchBrand(brand string) (*PlivoConfig, error) {
base := PlivoBrandKMSPathPrefix + "/" + brand + "/plivo"
authID, err := r.store.Get(brand, base+"/"+plivoFieldAuthID)
if err != nil {
return nil, err
}
authToken, err := r.store.Get(brand, base+"/"+plivoFieldAuthToken)
if err != nil {
return nil, err
}
senderID, err := r.store.Get(brand, base+"/"+plivoFieldSenderID)
if err != nil {
return nil, err
}
fromEmail, _ := r.store.Get(brand, base+"/"+plivoFieldFromEmail)
return &PlivoConfig{
Brand: brand,
AuthID: authID,
AuthToken: authToken,
SenderID: senderID,
FromEmail: fromEmail,
}, nil
}
// seedHanzo returns a store pre-populated with the default brand.
func seedHanzo(t *testing.T) fakeKMSStore {
t.Helper()
s := fakeKMSStore{}
s.put(DefaultBrand, "brand/hanzo/plivo/auth-id", "HANZO-DEFAULT-AUTH-ID")
s.put(DefaultBrand, "brand/hanzo/plivo/auth-token", "HANZO-DEFAULT-AUTH-TOKEN")
s.put(DefaultBrand, "brand/hanzo/plivo/sender-id", "+15555555555")
s.put(DefaultBrand, "brand/hanzo/plivo/from-email", "noreply@")
return s
}
// TestResolve_BrandOverride_UsesOverride: a brand with its own Plivo
// keys in KMS sends from those keys, not the Hanzo defaults.
func TestResolve_BrandOverride_UsesOverride(t *testing.T) {
t.Parallel()
s := seedHanzo(t)
s.put("hanzo", "brand/hanzo/plivo/auth-id", "HANZO-AUTH-ID")
s.put("hanzo", "brand/hanzo/plivo/auth-token", "HANZO-AUTH-TOKEN")
s.put("hanzo", "brand/hanzo/plivo/sender-id", "+14155551234")
s.put("hanzo", "brand/hanzo/plivo/from-email", "[email protected]")
r := &fakePlivoResolver{store: s}
cfg, err := r.ResolvePlivoConfig(context.Background(), "hanzo")
if err != nil {
t.Fatalf("ResolvePlivoConfig: %v", err)
}
if cfg.Brand != "hanzo" {
t.Errorf("Brand = %q, want %q", cfg.Brand, "hanzo")
}
if cfg.AuthID != "HANZO-AUTH-ID" {
t.Errorf("AuthID = %q, want HANZO-AUTH-ID — fell back to Hanzo?", cfg.AuthID)
}
if cfg.SenderID != "+14155551234" {
t.Errorf("SenderID = %q, want +14155551234", cfg.SenderID)
}
if !cfg.Override {
t.Errorf("Override = false, want true (brand has its own KMS entries)")
}
}
// TestResolve_NoBrandOverride_FallsBackToHanzo: a brand with no own
// KMS rows transparently uses the Hanzo defaults.
func TestResolve_NoBrandOverride_FallsBackToHanzo(t *testing.T) {
t.Parallel()
s := seedHanzo(t)
r := &fakePlivoResolver{store: s}
cfg, err := r.ResolvePlivoConfig(context.Background(), "lux")
if err != nil {
t.Fatalf("ResolvePlivoConfig: %v", err)
}
if cfg.Brand != DefaultBrand {
t.Errorf("Brand = %q, want %q (fell back to Hanzo)", cfg.Brand, DefaultBrand)
}
if cfg.AuthID != "HANZO-DEFAULT-AUTH-ID" {
t.Errorf("AuthID = %q, want HANZO-DEFAULT-AUTH-ID", cfg.AuthID)
}
if cfg.Override {
t.Errorf("Override = true, want false (no brand entries — used default)")
}
}
// TestResolve_HanzoMissing_FailsClosed: with no Hanzo default
// in KMS, ResolvePlivoConfig refuses to invent a fallback.
func TestResolve_HanzoMissing_FailsClosed(t *testing.T) {
t.Parallel()
s := fakeKMSStore{} // nothing in KMS
r := &fakePlivoResolver{store: s}
cfg, err := r.ResolvePlivoConfig(context.Background(), "zoo")
if err == nil {
t.Fatalf("ResolvePlivoConfig returned nil error (cfg=%+v) — expected fail-closed", cfg)
}
if !strings.Contains(err.Error(), "default brand") {
t.Errorf("error = %q, want it to mention default brand", err.Error())
}
}
// TestResolve_EmptyBrand_Errors: callers must always pass a brand. An
// empty string is a programming error (platform plugin guarantees
// X-Org-Id is set before this fires).
func TestResolve_EmptyBrand_Errors(t *testing.T) {
t.Parallel()
s := seedHanzo(t)
r := &fakePlivoResolver{store: s}
_, err := r.ResolvePlivoConfig(context.Background(), "")
if err == nil {
t.Fatalf("ResolvePlivoConfig(\"\") returned nil error — expected validation error")
}
if !strings.Contains(err.Error(), "brand is required") {
t.Errorf("error = %q, want it to mention brand is required", err.Error())
}
}
// TestResolve_DefaultBrandRequest_UsesHanzo: calling for "hanzo"
// itself returns the Hanzo defaults; Override is true because the
// caller's brand DOES match the resolved config's brand.
func TestResolve_DefaultBrandRequest_UsesHanzo(t *testing.T) {
t.Parallel()
s := seedHanzo(t)
r := &fakePlivoResolver{store: s}
cfg, err := r.ResolvePlivoConfig(context.Background(), DefaultBrand)
if err != nil {
t.Fatalf("ResolvePlivoConfig(default): %v", err)
}
if cfg.Brand != DefaultBrand {
t.Errorf("Brand = %q, want %q", cfg.Brand, DefaultBrand)
}
if !cfg.Override {
t.Errorf("Override = false, want true (caller IS the default brand)")
}
}
// TestResolve_BrandIncomplete_FallsBackOrErrors: a brand row with only
// auth-id (missing auth-token) is a misconfig — but how we surface it
// depends on which lookup hit it.
//
// In the real PlivoResolver, the inner fetchBrand returns an error that
// is NOT a "missing secret" — so the fallback short-circuits and the
// caller sees the misconfig directly rather than silently dropping to
// Hanzo defaults. We mirror that here.
func TestResolve_BrandIncomplete_DoesNotSilentlyFallBack(t *testing.T) {
t.Parallel()
s := seedHanzo(t)
// Only auth-id is set for hanzo — auth-token lookup will return
// "not found".
s.put("hanzo", "brand/hanzo/plivo/auth-id", "HANZO-AUTH-ID")
r := &fakePlivoResolver{store: s}
cfg, err := r.ResolvePlivoConfig(context.Background(), "hanzo")
// Behaviour: missing auth-token field on the brand path is a
// "secret not found" error per the fake KMS, so the brand-override
// path treats this as "no override exists" and falls back to
// Hanzo. That's the right choice — half a row is the same as
// no row from the resolver's perspective; the platform UI is
// responsible for never writing a partial row in the first place.
if err != nil {
t.Fatalf("ResolvePlivoConfig: %v", err)
}
if cfg.Brand != DefaultBrand {
t.Errorf("Brand = %q, want %q (partial brand row → fall back)", cfg.Brand, DefaultBrand)
}
}
// Validates the isMissingSecret helper handles every error string
// pattern we have seen in the wild.
func TestIsMissingSecret(t *testing.T) {
t.Parallel()
tests := []struct {
name string
err error
want bool
}{
{"nil", nil, false},
{"not found", errors.New("secret not found: foo"), true},
{"no such secret", errors.New("no such secret: foo"), true},
{"does not exist", errors.New("the secret does not exist"), true},
{"404", errors.New("http error 404"), true},
{"unrelated", errors.New("transport reset"), false},
{"auth fail", errors.New("unauthorized"), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := isMissingSecret(tt.err); got != tt.want {
t.Errorf("isMissingSecret(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}