package notify import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" "time" ) type got struct { mu sync.Mutex calls []*http.Request body [][]byte } func (g *got) handler(fail int) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { b, _ := io.ReadAll(r.Body) g.mu.Lock() defer g.mu.Unlock() g.calls = append(g.calls, r) g.body = append(g.body, b) if len(g.calls) <= fail { w.WriteHeader(http.StatusBadGateway) } } } func newNotifier(t *testing.T, c Config) *Notifier { t.Helper() n, err := New(context.Background(), c, nil) if err != nil { t.Fatal(err) } n.retry = time.Millisecond return n } func TestSignedJSON(t *testing.T) { var g got srv := httptest.NewServer(g.handler(0)) defer srv.Close() secret := filepath.Join(t.TempDir(), "s") os.WriteFile(secret, []byte("k3y\n"), 0o600) n := newNotifier(t, Config{Host: "gw1", Webhooks: []Webhook{{URL: srv.URL, SecretFile: secret}}}) n.Notify(ModelParked, "qwen", "model qwen is parked", map[string]any{"failures": 3}) n.Wait() if len(g.calls) != 1 { t.Fatalf("%d calls, want 1", len(g.calls)) } m := hmac.New(sha256.New, []byte("k3y")) m.Write(g.body[0]) if want := "sha256=" + hex.EncodeToString(m.Sum(nil)); g.calls[0].Header.Get("X-Llmgw-Signature") != want { t.Errorf("signature %q, want %q", g.calls[0].Header.Get("X-Llmgw-Signature"), want) } if g.calls[0].Header.Get("X-Llmgw-Event") != ModelParked { t.Errorf("event header %q", g.calls[0].Header.Get("X-Llmgw-Event")) } var e Event if err := json.Unmarshal(g.body[0], &e); err != nil { t.Fatal(err) } if e.Kind != ModelParked || e.Subject != "qwen" || e.Detail["gateway"] != "gw1" || e.Detail["failures"] != float64(3) { t.Errorf("event %+v", e) } } func TestSlackFiltersAndDedupes(t *testing.T) { var g got srv := httptest.NewServer(g.handler(0)) defer srv.Close() n := newNotifier(t, Config{Host: "gw1", Webhooks: []Webhook{{URL: srv.URL, Format: "slack", Events: []string{NodeLost}}}}) n.Notify(ModelParked, "qwen", "not for this hook", nil) n.Notify(NodeLost, "n1", "node n1 was lost", nil) n.Notify(NodeLost, "n1", "node n1 was lost again", nil) // within the hour: not sent n.Notify(NodeLost, "n2", "node n2 was lost", nil) n.Wait() if len(g.calls) != 2 { t.Fatalf("%d calls, want 2: %q", len(g.calls), g.body) } var m map[string]string json.Unmarshal(g.body[0], &m) if !strings.Contains(m["text"], "gw1") || !strings.Contains(m["text"], "node n1 was lost") { t.Errorf("text %q", m["text"]) } if g.calls[0].Header.Get("X-Llmgw-Signature") != "" { t.Error("signed without a secret") } } func TestRetries(t *testing.T) { var g got srv := httptest.NewServer(g.handler(2)) defer srv.Close() n := newNotifier(t, Config{Webhooks: []Webhook{{URL: srv.URL}}}) n.Notify(BudgetSpent, "org:1", "spent", nil) n.Wait() if len(g.calls) != 3 { t.Fatalf("%d attempts, want 3 (two failures, then delivered)", len(g.calls)) } var h got srv2 := httptest.NewServer(h.handler(10)) defer srv2.Close() n = newNotifier(t, Config{Webhooks: []Webhook{{URL: srv2.URL}}}) n.Notify(BudgetSpent, "org:1", "spent", nil) n.Wait() if len(h.calls) != 3 { t.Fatalf("%d attempts at a failing hook, want 3 then given up", len(h.calls)) } } func TestNilAndValidate(t *testing.T) { var n *Notifier n.Notify(NodeLost, "x", "y", nil) // must not panic n.Wait() if n, err := New(context.Background(), Config{}, nil); n != nil || err != nil { t.Fatalf("nothing configured: %v %v", n, err) } for _, c := range []Config{ {Webhooks: []Webhook{{URL: "ftp://x"}}}, {Webhooks: []Webhook{{URL: "https://x", Format: "teams"}}}, {Webhooks: []Webhook{{URL: "https://x", Events: []string{"model_gone"}}}}, } { if c.Validate() == nil { t.Errorf("%+v accepted", c) } } if _, err := New(context.Background(), Config{Webhooks: []Webhook{{URL: "https://x", SecretFile: "/nonexistent"}}}, nil); err == nil { t.Error("a missing secret file accepted") } } func TestTestSaysHowEachAnsweredWithoutTheSecretPath(t *testing.T) { var g got ok := httptest.NewServer(g.handler(0)) defer ok.Close() bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusForbidden) })) defer bad.Close() n := newNotifier(t, Config{Webhooks: []Webhook{ {URL: ok.URL + "/services/T0/B0/s3cret"}, {URL: bad.URL + "/hook"}, {URL: "http://127.0.0.1:1/services/T0/B0/s3cret"}, }}) defer n.Wait() res := n.Test(context.Background()) if len(res) != 3 || res[0].Err != "" || !strings.Contains(res[1].Err, "403") || res[2].Err == "" { t.Fatalf("%+v", res) } for _, r := range res { if strings.Contains(r.URL+r.Err, "s3cret") { t.Errorf("the secret path shown: %+v", r) } } }