mirror of
https://github.com/hanzoai/notify.git
synced 2026-11-02 14:03:55 +00:00
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.
270 lines
8.5 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|