Ctrl K
ComunidadGuías…

server/internal/handlers/webhook_uptime_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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"])
}