Implement phase 2.1 (human detection) and 2.2 (notifications): - Detection scorer computes 0.0-1.0 human likelihood from keystroke timing variance, special key usage, typing speed, command diversity, and session duration - Webhook notifier sends JSON POST to configured endpoints with deduplication, custom headers, and event filtering - RecordingChannel gains an event callback for feeding keystrokes to the scorer without coupling shell and detection packages - Server wires scorer into session lifecycle with periodic updates and threshold-based notification triggers - Web UI shows human score in session tables with highlighting - New config sections: [detection] and [[notify.webhooks]] Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
244 lines
6.1 KiB
Go
244 lines
6.1 KiB
Go
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)
|
|
}
|
|
}
|