package notify import ( "context" "encoding/json" "log/slog" "net/http" "net/http/httptest" "sync" "testing" "time" "git.t-juice.club/torjus/oubliette/internal/config" ) func testSession() SessionInfo { return SessionInfo{ ID: "test-session-123", IP: "1.2.3.4", Username: "root", ShellName: "bash", HumanScore: 0.85, ConnectedAt: FormatConnectedAt(time.Now()), } } func TestNotifier_PayloadStructure(t *testing.T) { var received webhookPayload var mu sync.Mutex done := make(chan struct{}) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() defer mu.Unlock() if err := json.NewDecoder(r.Body).Decode(&received); err != nil { t.Errorf("failed to decode payload: %v", err) } w.WriteHeader(http.StatusOK) close(done) })) defer srv.Close() webhooks := []config.WebhookNotifyConfig{ {URL: srv.URL}, } n := NewNotifier(webhooks, slog.Default()) session := testSession() n.Notify(context.Background(), EventHumanDetected, session) select { case <-done: case <-time.After(2 * time.Second): t.Fatal("timeout waiting for webhook") } mu.Lock() defer mu.Unlock() if received.Event != EventHumanDetected { t.Errorf("event: got %q, want %q", received.Event, EventHumanDetected) } if received.Session.ID != session.ID { t.Errorf("session ID: got %q, want %q", received.Session.ID, session.ID) } if received.Session.IP != session.IP { t.Errorf("session IP: got %q, want %q", received.Session.IP, session.IP) } if received.Session.HumanScore != session.HumanScore { t.Errorf("score: got %f, want %f", received.Session.HumanScore, session.HumanScore) } if received.Timestamp == "" { t.Error("timestamp should not be empty") } } func TestNotifier_CustomHeaders(t *testing.T) { var receivedHeaders http.Header done := make(chan struct{}) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { receivedHeaders = r.Header.Clone() w.WriteHeader(http.StatusOK) close(done) })) defer srv.Close() webhooks := []config.WebhookNotifyConfig{ { URL: srv.URL, Headers: map[string]string{ "Authorization": "Bearer test-token", "X-Custom": "my-value", }, }, } n := NewNotifier(webhooks, slog.Default()) n.Notify(context.Background(), EventSessionStarted, testSession()) select { case <-done: case <-time.After(2 * time.Second): t.Fatal("timeout waiting for webhook") } if got := receivedHeaders.Get("Authorization"); got != "Bearer test-token" { t.Errorf("Authorization header: got %q, want %q", got, "Bearer test-token") } if got := receivedHeaders.Get("X-Custom"); got != "my-value" { t.Errorf("X-Custom header: got %q, want %q", got, "my-value") } if got := receivedHeaders.Get("Content-Type"); got != "application/json" { t.Errorf("Content-Type: got %q, want %q", got, "application/json") } } func TestNotifier_Deduplication(t *testing.T) { var count int var mu sync.Mutex srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() count++ mu.Unlock() w.WriteHeader(http.StatusOK) })) defer srv.Close() webhooks := []config.WebhookNotifyConfig{{URL: srv.URL}} n := NewNotifier(webhooks, slog.Default()) session := testSession() // Send same event three times for the same session. for range 3 { n.Notify(context.Background(), EventHumanDetected, session) } // Allow goroutines to complete. time.Sleep(500 * time.Millisecond) mu.Lock() defer mu.Unlock() if count != 1 { t.Errorf("dedup: got %d sends, want 1", count) } } func TestNotifier_EventFiltering(t *testing.T) { var receivedEvents []string var mu sync.Mutex srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var payload webhookPayload _ = json.NewDecoder(r.Body).Decode(&payload) mu.Lock() receivedEvents = append(receivedEvents, payload.Event) mu.Unlock() w.WriteHeader(http.StatusOK) })) defer srv.Close() // Only subscribe to human_detected. webhooks := []config.WebhookNotifyConfig{ { URL: srv.URL, Events: []string{EventHumanDetected}, }, } n := NewNotifier(webhooks, slog.Default()) session := testSession() // Send both event types. n.Notify(context.Background(), EventSessionStarted, session) // Need a different session for human_detected to avoid dedup with same session. session2 := testSession() session2.ID = "test-session-456" n.Notify(context.Background(), EventHumanDetected, session2) time.Sleep(500 * time.Millisecond) mu.Lock() defer mu.Unlock() if len(receivedEvents) != 1 { t.Fatalf("event filtering: got %d events, want 1", len(receivedEvents)) } if receivedEvents[0] != EventHumanDetected { t.Errorf("filtered event: got %q, want %q", receivedEvents[0], EventHumanDetected) } } func TestNotifier_CleanupSession(t *testing.T) { var count int var mu sync.Mutex srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() count++ mu.Unlock() w.WriteHeader(http.StatusOK) })) defer srv.Close() webhooks := []config.WebhookNotifyConfig{{URL: srv.URL}} n := NewNotifier(webhooks, slog.Default()) session := testSession() n.Notify(context.Background(), EventHumanDetected, session) time.Sleep(200 * time.Millisecond) // Cleanup and resend — should work again. n.CleanupSession(session.ID) n.Notify(context.Background(), EventHumanDetected, session) time.Sleep(200 * time.Millisecond) mu.Lock() defer mu.Unlock() if count != 2 { t.Errorf("after cleanup: got %d sends, want 2", count) } } func TestNoopNotifier(t *testing.T) { // Should not panic. n := NoopNotifier{} n.Notify(context.Background(), EventHumanDetected, testSession()) n.CleanupSession("test") } func TestNewSender_NoWebhooks(t *testing.T) { sender := NewSender(nil, slog.Default()) if _, ok := sender.(NoopNotifier); !ok { t.Errorf("expected NoopNotifier, got %T", sender) } } func TestNewSender_WithWebhooks(t *testing.T) { webhooks := []config.WebhookNotifyConfig{{URL: "http://example.com"}} sender := NewSender(webhooks, slog.Default()) if _, ok := sender.(*Notifier); !ok { t.Errorf("expected *Notifier, got %T", sender) } }