server/internal/handlers/webhook_uptime_test.go
Rama main · réplica de lectura del repositorio original.
package handlers_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"blackbox/server/internal/handlers"
"blackbox/shared/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWebhookUptime_DownEvent_NoCorrelation(t *testing.T) {
database := newTestDB(t)
body := `{
"heartbeat": {"status": 0, "time": "2026-04-02T02:00:00Z", "msg": "Connection refused"},
"monitor": {"name": "my-app"}
}`
req := httptest.NewRequest(http.MethodPost, "/api/webhooks/uptime", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handlers.WebhookUptime(database, nil, nil, nil)(w, req)
assert.Equal(t, http.StatusCreated, w.Code)
var entries []types.Entry
require.NoError(t, database.Find(&entries).Error)
require.Len(t, entries, 1)
e := entries[0]
assert.Equal(t, "webhook", e.NodeName)
assert.Equal(t, "webhook", e.Source)
assert.Equal(t, "my-app", e.Service)
assert.Equal(t, "down", e.Event)
assert.Contains(t, e.Content, "my-app")
assert.Contains(t, e.Content, "Connection refused")
assert.Empty(t, e.CorrelatedID)
var meta map[string]interface{}
require.NoError(t, json.Unmarshal([]byte(e.Metadata), &meta))
assert.Equal(t, "my-app", meta["monitor"])
assert.Equal(t, "down", meta["status"])
assert.Nil(t, meta["possible_cause"])
}
func TestWebhookUptime_DownEvent_WithCorrelation(t *testing.T) {
database := newTestDB(t)
webhookTime := time.Date(2026, 4, 2, 2, 0, 0, 0, time.UTC)
agentEntry := types.Entry{
ID: "01AGENTENTRY000001",
Timestamp: webhookTime.Add(-30 * time.Second),
NodeName: "homelab-01",
Source: "docker",
Service: "my-app",
Event: "die",
Content: "container 'my-app' died (exit code: 137)",
Metadata: `{"exitCode":"137"}`,
}
require.NoError(t, database.Create(&agentEntry).Error)
body := `{
"heartbeat": {"status": 0, "time": "2026-04-02T02:00:00Z", "msg": "Connection refused"},
"monitor": {"name": "my-app"}
}`
req := httptest.NewRequest(http.MethodPost, "/api/webhooks/uptime", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handlers.WebhookUptime(database, nil, nil, nil)(w, req)
assert.Equal(t, http.StatusCreated, w.Code)
var entries []types.Entry
require.NoError(t, database.Where("source = ?", "webhook").Find(&entries).Error)
require.Len(t, entries, 1)
e := entries[0]
assert.Equal(t, "01AGENTENTRY000001", e.CorrelatedID)
var meta map[string]interface{}
require.NoError(t, json.Unmarshal([]byte(e.Metadata), &meta))
assert.Equal(t, "my-app", meta["monitor"])
assert.Equal(t, "down", meta["status"])
assert.Equal(t, "container 'my-app' died (exit code: 137)", meta["possible_cause"])
assert.Equal(t, "homelab-01", meta["cause_node"])
assert.Equal(t, "die", meta["cause_event"])
assert.Equal(t, "01AGENTENTRY000001", meta["cause_entry_id"])
assert.Equal(t, float64(80), meta["cause_score"])
}
func TestWebhookUptime_DownEvent_WithCorrelation_CaseInsensitiveService(t *testing.T) {
database := newTestDB(t)
webhookTime := time.Date(2026, 4, 2, 2, 0, 0, 0, time.UTC)
agentEntry := types.Entry{
ID: "01AGENTENTRYCASE001",
Timestamp: webhookTime.Add(-60 * time.Second),
NodeName: "homelab-01",
Source: "docker",
Service: "radarr",
Event: "stop",
Content: "container 'radarr' stopped",
}
require.NoError(t, database.Create(&agentEntry).Error)
body := `{
"heartbeat": {"status": 0, "time": "2026-04-02T02:00:00Z", "msg": "Connection refused"},
"monitor": {"name": "Radarr"}
}`
req := httptest.NewRequest(http.MethodPost, "/api/webhooks/uptime", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handlers.WebhookUptime(database, nil, nil, nil)(w, req)
assert.Equal(t, http.StatusCreated, w.Code)
var entry types.Entry
require.NoError(t, database.Where("source = ?", "webhook").First(&entry).Error)
assert.Equal(t, "radarr", entry.Service)
assert.Equal(t, "01AGENTENTRYCASE001", entry.CorrelatedID)
}
func TestWebhookUptime_UpEvent(t *testing.T) {
database := newTestDB(t)
body := `{
"heartbeat": {"status": 1, "time": "2026-04-02T02:05:00Z", "msg": "OK: 200 OK - 45ms"},
"monitor": {"name": "my-app"}
}`
req := httptest.NewRequest(http.MethodPost, "/api/webhooks/uptime", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handlers.WebhookUptime(database, nil, nil, nil)(w, req)
assert.Equal(t, http.StatusCreated, w.Code)
var entries []types.Entry
require.NoError(t, database.Find(&entries).Error)
require.Len(t, entries, 1)
e := entries[0]
assert.Equal(t, "up", e.Event)
assert.Contains(t, e.Content, "recovered")
assert.Empty(t, e.CorrelatedID)
var meta map[string]interface{}
require.NoError(t, json.Unmarshal([]byte(e.Metadata), &meta))
assert.Equal(t, "my-app", meta["monitor"])
assert.Equal(t, "up", meta["status"])
assert.Equal(t, "OK: 200 OK - 45ms", meta["recovery_msg"])
}
func TestWebhookUptime_UpEvent_AddsOutageDurationAndNormalizesService(t *testing.T) {
database := newTestDB(t)
downAt := time.Date(2026, 4, 2, 2, 0, 0, 0, time.UTC)
require.NoError(t, database.Create(&types.Entry{
ID: "01DOWNENTRY0000001",
Timestamp: downAt,
NodeName: "webhook",
Source: "webhook",
Service: "traefik-proxy",
Event: "down",
Content: "Monitor 'traefik-proxy' is down: timeout",
Metadata: `{"monitor":"traefik-proxy","status":"down"}`,
}).Error)
body := `{
"heartbeat": {"status": 1, "time": "2026-04-02T02:05:30Z", "msg": "OK"},
"monitor": {"name": " traefik-proxy "}
}`
req := httptest.NewRequest(http.MethodPost, "/api/webhooks/uptime", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handlers.WebhookUptime(database, nil, nil, nil)(w, req)
require.Equal(t, http.StatusCreated, w.Code)
var entry types.Entry
require.NoError(t, database.Where("event = ?", "up").First(&entry).Error)
assert.Equal(t, "traefik-proxy", entry.Service)
var meta map[string]interface{}
require.NoError(t, json.Unmarshal([]byte(entry.Metadata), &meta))
assert.Equal(t, float64(330), meta["duration_seconds"])
assert.Equal(t, "2026-04-02T02:00:00Z", meta["down_since"])
}
func TestWebhookUptime_UpEvent_UsesRawServiceHistoryDuringRollout(t *testing.T) {
database := newTestDB(t)
downAt := time.Date(2026, 4, 2, 2, 0, 0, 0, time.UTC)
require.NoError(t, database.Create(&types.Entry{
ID: "01DOWNENTRY0000003",
Timestamp: downAt,
NodeName: "webhook",
Source: "webhook",
Service: "Traefik-Proxy",
Event: "down",
Content: "Monitor 'traefik-proxy' is down: timeout",
Metadata: `{"monitor":"traefik-proxy","status":"down"}`,
}).Error)
body := `{
"heartbeat": {"status": 1, "time": "2026-04-02T02:05:30Z", "msg": "OK"},
"monitor": {"name": "Traefik-Proxy"}
}`
req := httptest.NewRequest(http.MethodPost, "/api/webhooks/uptime", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handlers.WebhookUptime(database, nil, nil, nil)(w, req)
require.Equal(t, http.StatusCreated, w.Code)
var entry types.Entry
require.NoError(t, database.Where("event = ?", "up").First(&entry).Error)
assert.Equal(t, "traefik-proxy", entry.Service)
var meta map[string]interface{}
require.NoError(t, json.Unmarshal([]byte(entry.Metadata), &meta))
assert.Equal(t, float64(330), meta["duration_seconds"])
assert.Equal(t, "2026-04-02T02:00:00Z", meta["down_since"])
}
func TestWebhookUptime_UpEvent_ClampsNegativeOutageDuration(t *testing.T) {
database := newTestDB(t)
downAt := time.Date(2026, 4, 2, 2, 10, 0, 0, time.UTC)
require.NoError(t, database.Create(&types.Entry{
ID: "01DOWNENTRY0000002",
Timestamp: downAt,
NodeName: "webhook",
Source: "webhook",
Service: "my-app",
Event: "down",
Content: "Monitor 'my-app' is down: timeout",
Metadata: `{"monitor":"my-app","status":"down"}`,
}).Error)
body := `{
"heartbeat": {"status": 1, "time": "2026-04-02T02:05:30Z", "msg": "OK"},
"monitor": {"name": "my-app"}
}`
req := httptest.NewRequest(http.MethodPost, "/api/webhooks/uptime", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handlers.WebhookUptime(database, nil, nil, nil)(w, req)
require.Equal(t, http.StatusCreated, w.Code)
var entry types.Entry
require.NoError(t, database.Where("event = ?", "up").First(&entry).Error)
var meta map[string]interface{}
require.NoError(t, json.Unmarshal([]byte(entry.Metadata), &meta))
assert.Nil(t, meta["duration_seconds"])
assert.Nil(t, meta["down_since"])
}
func TestWebhookUptime_UpEvent_PrefersLatestPriorDownAtOrBeforeRecovery(t *testing.T) {
database := newTestDB(t)
validDownAt := time.Date(2026, 4, 2, 2, 0, 0, 0, time.UTC)
laterEligibleDownAt := time.Date(2026, 4, 2, 2, 4, 0, 0, time.UTC)
skewedFutureDownAt := time.Date(2026, 4, 2, 2, 10, 0, 0, time.UTC)
require.NoError(t, database.Create(&types.Entry{
ID: "01DOWNENTRY0000004",
Timestamp: validDownAt,
NodeName: "webhook",
Source: "webhook",
Service: "my-app",
Event: "down",
Content: "Monitor 'my-app' is down: timeout",
Metadata: `{"monitor":"my-app","status":"down"}`,
}).Error)
require.NoError(t, database.Create(&types.Entry{
ID: "01DOWNENTRY0000007",
Timestamp: laterEligibleDownAt,
NodeName: "webhook",
Source: "webhook",
Service: "my-app",
Event: "down",
Content: "Monitor 'my-app' is down: retrying",
Metadata: `{"monitor":"my-app","status":"down"}`,
}).Error)
require.NoError(t, database.Create(&types.Entry{
ID: "01DOWNENTRY0000005",
Timestamp: skewedFutureDownAt,
NodeName: "webhook",
Source: "webhook",
Service: "my-app",
Event: "down",
Content: "Monitor 'my-app' is down: skew",
Metadata: `{"monitor":"my-app","status":"down"}`,
}).Error)
body := `{
"heartbeat": {"status": 1, "time": "2026-04-02T02:05:30Z", "msg": "OK"},
"monitor": {"name": "my-app"}
}`
req := httptest.NewRequest(http.MethodPost, "/api/webhooks/uptime", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handlers.WebhookUptime(database, nil, nil, nil)(w, req)
require.Equal(t, http.StatusCreated, w.Code)
var entry types.Entry
require.NoError(t, database.Where("event = ?", "up").First(&entry).Error)
var meta map[string]interface{}
require.NoError(t, json.Unmarshal([]byte(entry.Metadata), &meta))
assert.Equal(t, float64(90), meta["duration_seconds"])
assert.Equal(t, "2026-04-02T02:04:00Z", meta["down_since"])
}
func TestWebhookUptime_MissingMonitorName(t *testing.T) {
database := newTestDB(t)
body := `{"heartbeat": {"status": 0, "msg": "down"}, "monitor": {}}`
req := httptest.NewRequest(http.MethodPost, "/api/webhooks/uptime", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handlers.WebhookUptime(database, nil, nil, nil)(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
var entries []types.Entry
require.NoError(t, database.Find(&entries).Error)
assert.Empty(t, entries)
}
func TestWebhookUptime_RejectsWhitespaceOnlyMonitorName(t *testing.T) {
database := newTestDB(t)
body := `{"heartbeat": {"status": 0, "msg": "down"}, "monitor": {"name":" "}}`
req := httptest.NewRequest(http.MethodPost, "/api/webhooks/uptime", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handlers.WebhookUptime(database, nil, nil, nil)(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
var entries []types.Entry
require.NoError(t, database.Find(&entries).Error)
assert.Empty(t, entries)
}
func TestWebhookUptime_MalformedJSON(t *testing.T) {
database := newTestDB(t)
req := httptest.NewRequest(http.MethodPost, "/api/webhooks/uptime", bytes.NewBufferString("{bad json"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handlers.WebhookUptime(database, nil, nil, nil)(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestWebhookUptime_BadTimestampFallsBackAndSetsFlag(t *testing.T) {
database := newTestDB(t)
require.NoError(t, database.Create(&types.Entry{
ID: "01DOWNENTRY0000006",
Timestamp: time.Now().UTC().Add(-5 * time.Minute),
NodeName: "webhook",
Source: "webhook",
Service: "my-app",
Event: "down",
Content: "Monitor 'my-app' is down",
Metadata: `{"monitor":"my-app","status":"down"}`,
}).Error)
body := `{
"heartbeat": {"status": 1, "time": "not-a-timestamp", "msg": "down"},
"monitor": {"name": "my-app"}
}`
req := httptest.NewRequest(http.MethodPost, "/api/webhooks/uptime", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
before := time.Now().UTC()
handlers.WebhookUptime(database, nil, nil, nil)(w, req)
after := time.Now().UTC()
assert.Equal(t, http.StatusCreated, w.Code)
var entries []types.Entry
require.NoError(t, database.Order("timestamp DESC").Find(&entries).Error)
require.Len(t, entries, 2)
e := entries[0]
assert.Equal(t, "up", e.Event)
assert.True(t, e.Timestamp.After(before) || e.Timestamp.Equal(before))
assert.True(t, e.Timestamp.Before(after) || e.Timestamp.Equal(after))
var meta map[string]interface{}
require.NoError(t, json.Unmarshal([]byte(e.Metadata), &meta))
assert.Equal(t, true, meta["time_fallback"])
assert.Nil(t, meta["duration_seconds"])
assert.Nil(t, meta["down_since"])
}