orama/core/pkg/push/manager_test.go
anonpenguin23 f55c7269cd feat(gateway): implement self-service tenant push notifications
- Add `namespace_push_config` table for per-namespace provider settings
- Introduce `cluster_secret_path` to enable deterministic JWT signing and
  AES-256-GCM encryption for push credentials
- Update gateway config to support per-namespace overrides of push
  notification providers (ntfy/Expo)
- Bump version to 0.122.3
2026-05-08 11:23:53 +03:00

313 lines
9.2 KiB
Go

package push
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"go.uber.org/zap"
)
// fakeConfigStore is a minimal in-memory ConfigStore for tests.
type fakeConfigStore struct {
mu sync.Mutex
configs map[string]*Config
getErr error // optional injected error
calls atomic.Int32
}
func newFakeConfigStore() *fakeConfigStore {
return &fakeConfigStore{configs: map[string]*Config{}}
}
func (f *fakeConfigStore) Get(_ context.Context, ns string) (*Config, error) {
f.calls.Add(1)
if f.getErr != nil {
return nil, f.getErr
}
f.mu.Lock()
defer f.mu.Unlock()
cfg, ok := f.configs[ns]
if !ok {
return nil, ErrConfigNotFound
}
// Return a copy so the caller can't mutate our state.
cp := *cfg
return &cp, nil
}
func (f *fakeConfigStore) Upsert(_ context.Context, c Config) error {
f.mu.Lock()
defer f.mu.Unlock()
cp := c
f.configs[c.Namespace] = &cp
return nil
}
func (f *fakeConfigStore) Delete(_ context.Context, ns string) error {
f.mu.Lock()
defer f.mu.Unlock()
delete(f.configs, ns)
return nil
}
// managerFakeProvider is a stub PushProvider for manager tests.
// (The dispatcher tests already declare a `fakeProvider`; rename ours.)
type managerFakeProvider struct {
name string
sends atomic.Int32
}
func (p *managerFakeProvider) Name() string { return p.name }
func (p *managerFakeProvider) Send(_ context.Context, _ PushMessage) error {
p.sends.Add(1)
return nil
}
func (p *managerFakeProvider) SendBulk(_ context.Context, msgs []PushMessage) []error {
for range msgs {
p.sends.Add(1)
}
return nil
}
func TestManager_namespace_with_no_config_uses_defaults(t *testing.T) {
store := newFakeConfigStore() // empty
defaults := Defaults{NtfyBaseURL: "http://default-ntfy"}
var providerCalls atomic.Int32
factory := func(c Config) []PushProvider {
providerCalls.Add(1)
// Verify the manager passed defaults through to the factory.
if c.NtfyBaseURL != "http://default-ntfy" {
t.Errorf("factory got URL=%q, want default", c.NtfyBaseURL)
}
return []PushProvider{&managerFakeProvider{name: "ntfy"}}
}
m := NewManager(&fakeDeviceStore{}, store, defaults, factory, zap.NewNop())
d, err := m.dispatcherFor(context.Background(), "ns-A")
if err != nil {
t.Fatalf("expected default to satisfy: %v", err)
}
if d == nil {
t.Fatal("expected a dispatcher")
}
if providerCalls.Load() != 1 {
t.Errorf("factory should have been called once, got %d", providerCalls.Load())
}
}
func TestManager_namespace_config_overrides_defaults(t *testing.T) {
store := newFakeConfigStore()
store.Upsert(context.Background(), Config{
Namespace: "ns-A",
NtfyBaseURL: "http://override-ntfy",
})
defaults := Defaults{NtfyBaseURL: "http://default-ntfy"}
var seenURL string
factory := func(c Config) []PushProvider {
seenURL = c.NtfyBaseURL
return []PushProvider{&managerFakeProvider{name: "ntfy"}}
}
m := NewManager(&fakeDeviceStore{}, store, defaults, factory, zap.NewNop())
if _, err := m.dispatcherFor(context.Background(), "ns-A"); err != nil {
t.Fatalf("dispatcherFor: %v", err)
}
if seenURL != "http://override-ntfy" {
t.Errorf("factory got URL=%q, want the namespace override", seenURL)
}
}
func TestManager_no_config_no_defaults_returns_ErrPushNotConfigured(t *testing.T) {
store := newFakeConfigStore()
factory := func(_ Config) []PushProvider { return nil }
m := NewManager(&fakeDeviceStore{}, store, Defaults{}, factory, zap.NewNop())
_, err := m.dispatcherFor(context.Background(), "ns-A")
if !errors.Is(err, ErrPushNotConfigured) {
t.Errorf("expected ErrPushNotConfigured, got %v", err)
}
}
func TestManager_caches_dispatchers_per_namespace(t *testing.T) {
store := newFakeConfigStore()
store.Upsert(context.Background(), Config{Namespace: "ns-A", NtfyBaseURL: "u"})
var factoryCalls atomic.Int32
factory := func(_ Config) []PushProvider {
factoryCalls.Add(1)
return []PushProvider{&managerFakeProvider{name: "ntfy"}}
}
m := NewManager(&fakeDeviceStore{}, store, Defaults{}, factory, zap.NewNop())
// Two calls for the same namespace = one factory invocation (cache hit).
for i := 0; i < 5; i++ {
if _, err := m.dispatcherFor(context.Background(), "ns-A"); err != nil {
t.Fatalf("iter %d: %v", i, err)
}
}
if factoryCalls.Load() != 1 {
t.Errorf("expected 1 factory call due to cache, got %d", factoryCalls.Load())
}
}
func TestManager_invalidate_forces_rebuild(t *testing.T) {
store := newFakeConfigStore()
store.Upsert(context.Background(), Config{Namespace: "ns-A", NtfyBaseURL: "v1"})
var seenURLs []string
factory := func(c Config) []PushProvider {
seenURLs = append(seenURLs, c.NtfyBaseURL)
return []PushProvider{&managerFakeProvider{name: "ntfy"}}
}
m := NewManager(&fakeDeviceStore{}, store, Defaults{}, factory, zap.NewNop())
if _, err := m.dispatcherFor(context.Background(), "ns-A"); err != nil {
t.Fatal(err)
}
// Update config + invalidate.
store.Upsert(context.Background(), Config{Namespace: "ns-A", NtfyBaseURL: "v2"})
m.Invalidate("ns-A")
if _, err := m.dispatcherFor(context.Background(), "ns-A"); err != nil {
t.Fatal(err)
}
if len(seenURLs) != 2 || seenURLs[0] != "v1" || seenURLs[1] != "v2" {
t.Errorf("expected factory to be called twice with v1 then v2, got %v", seenURLs)
}
}
func TestManager_per_namespace_isolation(t *testing.T) {
// ns-A has its own config; ns-B has none and uses defaults.
store := newFakeConfigStore()
store.Upsert(context.Background(), Config{Namespace: "ns-A", NtfyBaseURL: "ns-A-url"})
defaults := Defaults{NtfyBaseURL: "default-url"}
urlByNS := make(map[string]string)
var mu sync.Mutex
factory := func(c Config) []PushProvider {
mu.Lock()
urlByNS[c.Namespace] = c.NtfyBaseURL
mu.Unlock()
return []PushProvider{&managerFakeProvider{name: "ntfy"}}
}
m := NewManager(&fakeDeviceStore{}, store, defaults, factory, zap.NewNop())
if _, err := m.dispatcherFor(context.Background(), "ns-A"); err != nil {
t.Fatal(err)
}
if _, err := m.dispatcherFor(context.Background(), "ns-B"); err != nil {
t.Fatal(err)
}
mu.Lock()
defer mu.Unlock()
if urlByNS["ns-A"] != "ns-A-url" {
t.Errorf("ns-A got %q, want ns-A-url", urlByNS["ns-A"])
}
if urlByNS["ns-B"] != "default-url" {
t.Errorf("ns-B got %q, want default-url", urlByNS["ns-B"])
}
}
func TestManager_IsConfigured_distinguishes_states(t *testing.T) {
store := newFakeConfigStore()
store.Upsert(context.Background(), Config{Namespace: "with-cfg", NtfyBaseURL: "u"})
// Without defaults, only namespace with config returns true.
m1 := NewManager(&fakeDeviceStore{}, store, Defaults{}, nil, zap.NewNop())
if !m1.IsConfigured(context.Background(), "with-cfg") {
t.Error("expected with-cfg to be configured")
}
if m1.IsConfigured(context.Background(), "no-cfg") {
t.Error("expected no-cfg to NOT be configured (no defaults)")
}
// With defaults, every namespace is configured.
m2 := NewManager(&fakeDeviceStore{}, store, Defaults{NtfyBaseURL: "d"}, nil, zap.NewNop())
if !m2.IsConfigured(context.Background(), "no-cfg") {
t.Error("expected no-cfg to be configured via defaults")
}
}
func TestManager_concurrent_dispatcherFor_no_race(t *testing.T) {
// Run with -race.
store := newFakeConfigStore()
store.Upsert(context.Background(), Config{Namespace: "ns", NtfyBaseURL: "u"})
factory := func(_ Config) []PushProvider { return []PushProvider{&managerFakeProvider{name: "ntfy"}} }
m := NewManager(&fakeDeviceStore{}, store, Defaults{}, factory, zap.NewNop())
var wg sync.WaitGroup
for g := 0; g < 16; g++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 50; i++ {
_, _ = m.dispatcherFor(context.Background(), "ns")
}
}()
}
wg.Wait()
}
// fakeDeviceStore is a stub PushDeviceStore for manager tests.
type fakeDeviceStore struct{}
func (s *fakeDeviceStore) Upsert(_ context.Context, _ PushDevice) error { return nil }
func (s *fakeDeviceStore) Delete(_ context.Context, _, _ string) error { return nil }
func (s *fakeDeviceStore) ListForUser(_ context.Context, _, _ string) ([]PushDevice, error) {
return nil, nil
}
func TestConfig_Redacted_omits_secrets(t *testing.T) {
cfg := &Config{
Namespace: "ns",
NtfyBaseURL: "https://ntfy.example",
NtfyAuthToken: "super-secret-token",
ExpoAccessToken: "another-super-secret",
UpdatedAt: 12345,
UpdatedBy: "admin",
}
r := cfg.Redacted()
if r.NtfyBaseURL != "https://ntfy.example" {
t.Errorf("ntfy URL must be present (it's not a secret), got %q", r.NtfyBaseURL)
}
if !r.HasNtfyAuthToken {
t.Error("HasNtfyAuthToken must reflect that the token is set")
}
if !r.HasExpoAccessToken {
t.Error("HasExpoAccessToken must reflect that the token is set")
}
// Most importantly: the tokens themselves must not appear anywhere
// in the redacted view. Walk the struct's exported fields.
if r.UpdatedAt != 12345 {
t.Errorf("UpdatedAt should round-trip, got %d", r.UpdatedAt)
}
}
func TestConfig_IsEmpty(t *testing.T) {
cases := []struct {
name string
c *Config
want bool
}{
{"nil pointer", nil, true},
{"zero value", &Config{}, true},
{"namespace only", &Config{Namespace: "ns"}, true},
{"with ntfy URL", &Config{NtfyBaseURL: "u"}, false},
{"with expo token", &Config{ExpoAccessToken: "t"}, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := c.c.IsEmpty(); got != c.want {
t.Errorf("IsEmpty() = %v, want %v", got, c.want)
}
})
}
}