Ctrl K
ComunidadGuías…

server/internal/handlers/admin_sources_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"

	dbpkg "blackbox/server/internal/db"
	"blackbox/server/internal/handlers"
	"blackbox/server/internal/models"
	"github.com/glebarez/sqlite"
	"github.com/go-chi/chi/v5"
	"github.com/stretchr/testify/require"
	"gorm.io/gorm"
)

func newSourcesTestDB(t *testing.T) *gorm.DB {
	t.Helper()
	db, err := dbpkg.Init(":memory:")
	require.NoError(t, err)
	sqlDB, err := db.DB()
	require.NoError(t, err)
	t.Cleanup(func() { _ = sqlDB.Close() })
	return db
}

func TestListSources_Empty(t *testing.T) {
	db := newSourcesTestDB(t)
	req := httptest.NewRequest(http.MethodGet, "/api/admin/sources", nil)
	w := httptest.NewRecorder()
	// This relies on the handler injecting legacy default capabilities for nodes with empty stored capabilities.
	handlers.ListSources(db)(w, req)
	require.Equal(t, http.StatusOK, w.Code)
	var resp map[string]any
	require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
	require.Contains(t, resp, "server")
	require.Contains(t, resp, "nodes")
}

func TestCreateSource_Valid(t *testing.T) {
	db := newSourcesTestDB(t)
	body, _ := json.Marshal(map[string]any{
		"type": "filewatcher", "scope": "agent", "node_id": "homelab-01",
		"name": "watcher", "config": map[string]any{"redact_secrets": false},
		"enabled": true,
	})
	req := httptest.NewRequest(http.MethodPost, "/api/admin/sources", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	handlers.CreateSource(db)(w, req)
	require.Equal(t, http.StatusCreated, w.Code)

	var inst models.DataSourceInstance
	require.NoError(t, db.Where("type = ?", "filewatcher").First(&inst).Error)
	require.Equal(t, "watcher", inst.Name)
}

func TestCreateSource_UnknownType(t *testing.T) {
	db := newSourcesTestDB(t)
	body, _ := json.Marshal(map[string]any{"type": "banana", "scope": "agent", "name": "x", "config": map[string]any{}})
	req := httptest.NewRequest(http.MethodPost, "/api/admin/sources", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	handlers.CreateSource(db)(w, req)
	require.Equal(t, http.StatusBadRequest, w.Code)
}

func TestCreateSource_WhitespaceNameRejected(t *testing.T) {
	db := newSourcesTestDB(t)
	body, _ := json.Marshal(map[string]any{
		"type": "filewatcher", "scope": "agent", "node_id": "homelab-01",
		"name": "   ", "config": map[string]any{"redact_secrets": false},
	})
	req := httptest.NewRequest(http.MethodPost, "/api/admin/sources", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	handlers.CreateSource(db)(w, req)
	require.Equal(t, http.StatusBadRequest, w.Code)
}

func TestUpdateSource(t *testing.T) {
	db := newSourcesTestDB(t)
	nodeName := "homelab-01"
	inst := models.DataSourceInstance{
		ID: "testid", Type: "filewatcher", Scope: "agent", NodeID: &nodeName,
		Name: "watcher", Config: `{"redact_secrets":true}`, Enabled: true,
	}
	require.NoError(t, db.Create(&inst).Error)

	body, _ := json.Marshal(map[string]any{"name": "watcher-updated", "enabled": false, "config": map[string]any{"redact_secrets": false}})
	r := chi.NewRouter()
	r.Put("/api/admin/sources/{id}", handlers.UpdateSource(db))
	req := httptest.NewRequest(http.MethodPut, "/api/admin/sources/testid", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	r.ServeHTTP(w, req)
	require.Equal(t, http.StatusOK, w.Code)

	var updated models.DataSourceInstance
	require.NoError(t, db.First(&updated, "id = ?", "testid").Error)
	require.Equal(t, "watcher-updated", updated.Name)
	require.False(t, updated.Enabled)
}

func TestUpdateSource_PreservesSensitiveConfigWhenOmitted(t *testing.T) {
	db := newSourcesTestDB(t)
	inst := models.DataSourceInstance{
		ID: "testid", Type: "webhook_uptime_kuma", Scope: "server",
		Name: "UK", Config: `{"secret":"keep-me","note":"existing"}`,
		Enabled: true,
	}
	require.NoError(t, db.Create(&inst).Error)

	body, err := json.Marshal(map[string]any{
		"config": map[string]any{"note": "updated"},
	})
	require.NoError(t, err)

	r := chi.NewRouter()
	r.Put("/api/admin/sources/{id}", handlers.UpdateSource(db))
	req := httptest.NewRequest(http.MethodPut, "/api/admin/sources/testid", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	r.ServeHTTP(w, req)
	require.Equal(t, http.StatusOK, w.Code)

	var updated models.DataSourceInstance
	require.NoError(t, db.First(&updated, "id = ?", "testid").Error)
	var cfg map[string]any
	require.NoError(t, json.Unmarshal([]byte(updated.Config), &cfg))
	require.Equal(t, "updated", cfg["note"])
	require.Equal(t, "keep-me", cfg["secret"])
	var respInst models.DataSourceInstance
	require.NoError(t, json.Unmarshal(w.Body.Bytes(), &respInst))
	require.NotContains(t, respInst.Config, "keep-me")
}

func TestUpdateSource_RejectsSensitiveConfigWhenNullOrNonString(t *testing.T) {
	db := newSourcesTestDB(t)
	inst := models.DataSourceInstance{
		ID: "testid", Type: "webhook_uptime_kuma", Scope: "server",
		Name: "UK", Config: `{"secret":"keep-me","note":"existing"}`,
		Enabled: true,
	}
	require.NoError(t, db.Create(&inst).Error)

	for name, body := range map[string]string{
		"null":       `{"config":{"secret":null,"note":"updated"}}`,
		"non-string": `{"config":{"secret":123,"note":"updated"}}`,
	} {
		t.Run(name, func(t *testing.T) {
			r := chi.NewRouter()
			r.Put("/api/admin/sources/{id}", handlers.UpdateSource(db))
			req := httptest.NewRequest(http.MethodPut, "/api/admin/sources/testid", bytes.NewReader([]byte(body)))
			req.Header.Set("Content-Type", "application/json")
			w := httptest.NewRecorder()
			r.ServeHTTP(w, req)
			require.Equal(t, http.StatusBadRequest, w.Code)
		})
	}
}

func TestUpdateSource_DisablingWebhookClearsRuntimeSecretCache(t *testing.T) {
	db := newSourcesTestDB(t)
	t.Cleanup(func() {
		handlers.ResetWebhookSecretCacheForTesting("webhook_uptime_kuma")
	})
	inst := models.DataSourceInstance{
		ID: "testid", Type: "webhook_uptime_kuma", Scope: "server",
		Name: "UK", Config: `{"secret":"keep-me"}`, Enabled: true,
	}
	require.NoError(t, db.Create(&inst).Error)
	require.Equal(t, "keep-me", handlers.PrimeWebhookSecretCache(db, "webhook_uptime_kuma", ""))

	r := chi.NewRouter()
	r.Put("/api/admin/sources/{id}", handlers.UpdateSource(db))
	req := httptest.NewRequest(http.MethodPut, "/api/admin/sources/testid", bytes.NewReader([]byte(`{"enabled":false}`)))
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	r.ServeHTTP(w, req)
	require.Equal(t, http.StatusOK, w.Code)

	require.Equal(t, "", handlers.GetCachedWebhookSecret(db, "webhook_uptime_kuma", ""))
}

func TestDeleteSource(t *testing.T) {
	db := newSourcesTestDB(t)
	require.NoError(t, db.Create(&models.DataSourceInstance{ID: "del1", Type: "filewatcher", Scope: "agent", Name: "x", Config: "{}"}).Error)

	r := chi.NewRouter()
	r.Delete("/api/admin/sources/{id}", handlers.DeleteSource(db))
	req := httptest.NewRequest(http.MethodDelete, "/api/admin/sources/del1", nil)
	w := httptest.NewRecorder()
	r.ServeHTTP(w, req)
	require.Equal(t, http.StatusNoContent, w.Code)

	var count int64
	db.Model(&models.DataSourceInstance{}).Where("id = ?", "del1").Count(&count)
	require.Equal(t, int64(0), count)
}

func TestDeleteSource_ClearsRuntimeWebhookSecretCache(t *testing.T) {
	db := newSourcesTestDB(t)
	t.Cleanup(func() {
		handlers.ResetWebhookSecretCacheForTesting("webhook_uptime_kuma")
	})
	require.NoError(t, db.Create(&models.DataSourceInstance{
		ID: "wh1", Type: "webhook_uptime_kuma", Scope: "server",
		Name: "UK", Config: `{"secret":"db-secret"}`, Enabled: true,
	}).Error)
	require.Equal(t, "db-secret", handlers.PrimeWebhookSecretCache(db, "webhook_uptime_kuma", ""))

	r := chi.NewRouter()
	r.Delete("/api/admin/sources/{id}", handlers.DeleteSource(db))
	req := httptest.NewRequest(http.MethodDelete, "/api/admin/sources/wh1", nil)
	w := httptest.NewRecorder()
	r.ServeHTTP(w, req)
	require.Equal(t, http.StatusNoContent, w.Code)

	require.Equal(t, "", handlers.GetCachedWebhookSecret(db, "webhook_uptime_kuma", ""))
}

func TestCreateSource_SingletonEnforced(t *testing.T) {
	db := newSourcesTestDB(t)
	// Create first systemd instance
	nodeName := "homelab-01"
	require.NoError(t, db.Create(&models.DataSourceInstance{
		ID: "sys1", Type: "systemd", Scope: "agent", NodeID: &nodeName, Name: "Systemd", Config: "{}",
	}).Error)

	// Attempt to create second systemd instance for same node — must fail 409
	body, _ := json.Marshal(map[string]any{
		"type": "systemd", "scope": "agent", "node_id": "homelab-01", "name": "Systemd2", "config": map[string]any{"units": []string{}},
	})
	req := httptest.NewRequest(http.MethodPost, "/api/admin/sources", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	handlers.CreateSource(db)(w, req)
	require.Equal(t, http.StatusConflict, w.Code)
}

func TestCreateSource_ServerScopedRejectsNodeID(t *testing.T) {
	db := newSourcesTestDB(t)
	body, _ := json.Marshal(map[string]any{
		"type": "webhook_uptime_kuma", "scope": "server", "node_id": "homelab-01",
		"name": "UK", "config": map[string]any{"secret": "x"},
	})
	req := httptest.NewRequest(http.MethodPost, "/api/admin/sources", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	handlers.CreateSource(db)(w, req)
	require.Equal(t, http.StatusBadRequest, w.Code)
}

func TestCreateSource_AgentScopedRequiresNodeID(t *testing.T) {
	db := newSourcesTestDB(t)
	body, _ := json.Marshal(map[string]any{
		"type": "systemd", "scope": "agent", "name": "Sys", "config": map[string]any{"units": []string{}},
	})
	req := httptest.NewRequest(http.MethodPost, "/api/admin/sources", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	handlers.CreateSource(db)(w, req)
	require.Equal(t, http.StatusBadRequest, w.Code)
}

func TestGetWebhookSecret_DBHit(t *testing.T) {
	db := newSourcesTestDB(t)
	require.NoError(t, db.Create(&models.DataSourceInstance{
		ID: "wh1", Type: "webhook_uptime_kuma", Scope: "server",
		Name: "UK", Config: `{"secret":"db-secret"}`, Enabled: true,
	}).Error)
	result := handlers.GetWebhookSecret(db, "webhook_uptime_kuma")
	require.Equal(t, "db-secret", result)
}

func TestGetWebhookSecret_MissingSourceDisablesWebhook(t *testing.T) {
	db := newSourcesTestDB(t)
	result := handlers.GetWebhookSecret(db, "webhook_uptime_kuma")
	require.Equal(t, "", result)
}

func TestGetWebhookSecret_EmptyDBSecretDisablesWebhook(t *testing.T) {
	db := newSourcesTestDB(t)
	require.NoError(t, db.Create(&models.DataSourceInstance{
		ID: "wh2", Type: "webhook_uptime_kuma", Scope: "server",
		Name: "UK", Config: `{"secret":""}`, Enabled: true,
	}).Error)
	result := handlers.GetWebhookSecret(db, "webhook_uptime_kuma")
	require.Equal(t, "", result)
}

func TestGetWebhookSecret_DisabledOrMissingSourceDisablesWebhook(t *testing.T) {
	db := newSourcesTestDB(t)
	require.NoError(t, db.Select("*").Create(&models.DataSourceInstance{
		ID: "wh1", Type: "webhook_uptime_kuma", Scope: "server",
		Name: "UK", Config: `{"secret":"db-secret"}`, Enabled: false,
	}).Error)
	require.Equal(t, "", handlers.GetWebhookSecret(db, "webhook_uptime_kuma"))

	require.NoError(t, db.Delete(&models.DataSourceInstance{}, "id = ?", "wh1").Error)
	require.Equal(t, "", handlers.GetWebhookSecret(db, "webhook_uptime_kuma"))
}

// This intentionally bypasses dbpkg.Init and newSourcesTestDB because the production
// uniqueness constraint would prevent creating two server-scoped webhook rows, which
// would make handlers.GetWebhookSecret impossible to verify for oldest-row preference.
func TestGetWebhookSecret_PrefersOldestEnabledInstance(t *testing.T) {
	db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
	require.NoError(t, err)
	require.NoError(t, db.AutoMigrate(&models.DataSourceInstance{}))
	now := time.Now().UTC()
	require.NoError(t, db.Create(&models.DataSourceInstance{
		ID: "wh-new", Type: "webhook_uptime_kuma", Scope: "server",
		Name: "UK new", Config: `{"secret":"newer-secret"}`, Enabled: true,
		CreatedAt: now, UpdatedAt: now,
	}).Error)
	require.NoError(t, db.Create(&models.DataSourceInstance{
		ID: "wh-old", Type: "webhook_uptime_kuma", Scope: "server",
		Name: "UK old", Config: `{"secret":"older-secret"}`, Enabled: true,
		CreatedAt: now.Add(-time.Hour), UpdatedAt: now.Add(-time.Hour),
	}).Error)

	result := handlers.GetWebhookSecret(db, "webhook_uptime_kuma")
	require.Equal(t, "older-secret", result)
}

func TestListSources_WebhookSecretRedacted(t *testing.T) {
	db := newSourcesTestDB(t)
	require.NoError(t, db.Create(&models.DataSourceInstance{
		ID: "wh1", Type: "webhook_uptime_kuma", Scope: "server",
		Name: "UK", Config: `{"secret":"supersecret"}`, Enabled: true,
	}).Error)
	req := httptest.NewRequest(http.MethodGet, "/api/admin/sources", nil)
	w := httptest.NewRecorder()
	handlers.ListSources(db)(w, req)
	require.Equal(t, http.StatusOK, w.Code)

	body := w.Body.String()
	require.NotContains(t, body, "supersecret")
}

func TestListSources_OrphansIncluded(t *testing.T) {
	db := newSourcesTestDB(t)
	nodeName := "homelab-01"
	require.NoError(t, db.Create(&models.Node{
		ID: "n1", Name: nodeName, Capabilities: "[]",
	}).Error)
	require.NoError(t, db.Create(&models.DataSourceInstance{
		ID: "fw1", Type: "filewatcher", Scope: "agent", NodeID: &nodeName,
		Name: "Watcher", Config: `{"redact_secrets":true}`, Enabled: true,
	}).Error)
	require.NoError(t, db.Create(&models.DataSourceInstance{
		ID: "orphan1", Type: "filewatcher", Scope: "agent",
		Name: "Orphan", Config: `{"redact_secrets":true}`, Enabled: true,
	}).Error)

	req := httptest.NewRequest(http.MethodGet, "/api/admin/sources", nil)
	w := httptest.NewRecorder()
	handlers.ListSources(db)(w, req)
	require.Equal(t, http.StatusOK, w.Code)

	var resp struct {
		Nodes map[string]struct {
			Capabilities []string                    `json:"capabilities"`
			Sources      []models.DataSourceInstance `json:"sources"`
		} `json:"nodes"`
		Orphans []models.DataSourceInstance `json:"orphans"`
	}
	require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
	require.NotEmpty(t, resp.Nodes[nodeName].Capabilities)
	require.Contains(t, resp.Nodes[nodeName].Capabilities, "filewatcher")
	require.Len(t, resp.Nodes[nodeName].Sources, 1)
	require.Len(t, resp.Orphans, 1)
	require.Equal(t, "orphan1", resp.Orphans[0].ID)
}

func TestCreateSource_InvalidConfigRejected(t *testing.T) {
	db := newSourcesTestDB(t)
	// Send a non-object JSON value as config
	raw := []byte(`{"type":"filewatcher","scope":"agent","node_id":"homelab-01","name":"watcher","config":42,"enabled":true}`)
	req := httptest.NewRequest(http.MethodPost, "/api/admin/sources", bytes.NewReader(raw))
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	handlers.CreateSource(db)(w, req)
	require.Equal(t, http.StatusBadRequest, w.Code)
}

func TestCreateSource_RejectsVirtualDockerSource(t *testing.T) {
	db := newSourcesTestDB(t)
	raw := []byte(`{"type":"docker","scope":"agent","node_id":"homelab-01","name":"docker","config":{},"enabled":true}`)
	req := httptest.NewRequest(http.MethodPost, "/api/admin/sources", bytes.NewReader(raw))
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	handlers.CreateSource(db)(w, req)
	require.Equal(t, http.StatusBadRequest, w.Code)
	require.Contains(t, w.Body.String(), "virtual")
}

func TestCreateSource_FileWatcherRequiresRedactSecrets(t *testing.T) {
	db := newSourcesTestDB(t)
	raw := []byte(`{"type":"filewatcher","scope":"agent","node_id":"homelab-01","name":"watcher","config":{},"enabled":true}`)
	req := httptest.NewRequest(http.MethodPost, "/api/admin/sources", bytes.NewReader(raw))
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	handlers.CreateSource(db)(w, req)
	require.Equal(t, http.StatusBadRequest, w.Code)
	require.Contains(t, w.Body.String(), "redact_secrets is required")
}

func TestCreateSource_FileWatcherRequiresBooleanRedactSecrets(t *testing.T) {
	db := newSourcesTestDB(t)
	raw := []byte(`{"type":"filewatcher","scope":"agent","node_id":"homelab-01","name":"watcher","config":{"redact_secrets":"false"},"enabled":true}`)
	req := httptest.NewRequest(http.MethodPost, "/api/admin/sources", bytes.NewReader(raw))
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	handlers.CreateSource(db)(w, req)
	require.Equal(t, http.StatusBadRequest, w.Code)
	require.Contains(t, w.Body.String(), "redact_secrets must be a boolean")
}

func TestCreateSource_NullConfigRejected(t *testing.T) {
	db := newSourcesTestDB(t)
	raw := []byte(`{"type":"filewatcher","scope":"agent","node_id":"homelab-01","name":"watcher","config":null,"enabled":true}`)
	req := httptest.NewRequest(http.MethodPost, "/api/admin/sources", bytes.NewReader(raw))
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	handlers.CreateSource(db)(w, req)
	require.Equal(t, http.StatusBadRequest, w.Code)
}

func TestUpdateSource_NullConfigRejected(t *testing.T) {
	db := newSourcesTestDB(t)
	nodeName := "homelab-01"
	inst := models.DataSourceInstance{
		ID: "testid", Type: "filewatcher", Scope: "agent", NodeID: &nodeName,
		Name: "watcher", Config: `{"redact_secrets":true}`, Enabled: true,
	}
	require.NoError(t, db.Create(&inst).Error)

	r := chi.NewRouter()
	r.Put("/api/admin/sources/{id}", handlers.UpdateSource(db))
	req := httptest.NewRequest(http.MethodPut, "/api/admin/sources/testid", bytes.NewReader([]byte(`{"config":null}`)))
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	r.ServeHTTP(w, req)
	require.Equal(t, http.StatusBadRequest, w.Code)
}

func TestUpdateSource_EmptyNameRejected(t *testing.T) {
	db := newSourcesTestDB(t)
	nodeName := "homelab-01"
	inst := models.DataSourceInstance{
		ID: "testid", Type: "filewatcher", Scope: "agent", NodeID: &nodeName,
		Name: "watcher", Config: `{"redact_secrets":true}`, Enabled: true,
	}
	require.NoError(t, db.Create(&inst).Error)

	r := chi.NewRouter()
	r.Put("/api/admin/sources/{id}", handlers.UpdateSource(db))
	req := httptest.NewRequest(http.MethodPut, "/api/admin/sources/testid", bytes.NewReader([]byte(`{"name":""}`)))
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	r.ServeHTTP(w, req)
	require.Equal(t, http.StatusBadRequest, w.Code)
}

func TestUpdateSource_WhitespaceNameRejected(t *testing.T) {
	db := newSourcesTestDB(t)
	nodeName := "homelab-01"
	inst := models.DataSourceInstance{
		ID: "testid", Type: "filewatcher", Scope: "agent", NodeID: &nodeName,
		Name: "watcher", Config: `{"redact_secrets":true}`, Enabled: true,
	}
	require.NoError(t, db.Create(&inst).Error)

	r := chi.NewRouter()
	r.Put("/api/admin/sources/{id}", handlers.UpdateSource(db))
	req := httptest.NewRequest(http.MethodPut, "/api/admin/sources/testid", bytes.NewReader([]byte(`{"name":"   "}`)))
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	r.ServeHTTP(w, req)
	require.Equal(t, http.StatusBadRequest, w.Code)
}

func TestCreateSource_SystemdRequiresUnitsArray(t *testing.T) {
	db := newSourcesTestDB(t)
	raw := []byte(`{"type":"systemd","scope":"agent","node_id":"homelab-01","name":"systemd","config":{"units":"nginx.service"},"enabled":true}`)
	req := httptest.NewRequest(http.MethodPost, "/api/admin/sources", bytes.NewReader(raw))
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	handlers.CreateSource(db)(w, req)
	require.Equal(t, http.StatusBadRequest, w.Code)
	require.Contains(t, w.Body.String(), "units must be an array of strings")
}

func TestCreateSource_SystemdAllowsEmptyUnitList(t *testing.T) {
	db := newSourcesTestDB(t)
	raw := []byte(`{"type":"systemd","scope":"agent","node_id":"homelab-01","name":"systemd","config":{"units":[]},"enabled":true}`)
	req := httptest.NewRequest(http.MethodPost, "/api/admin/sources", bytes.NewReader(raw))
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	handlers.CreateSource(db)(w, req)
	require.Equal(t, http.StatusCreated, w.Code)

	var inst models.DataSourceInstance
	require.NoError(t, db.Where("type = ? AND node_id = ?", "systemd", "homelab-01").First(&inst).Error)
	require.JSONEq(t, `{"units":[]}`, inst.Config)
}

func TestCreateSource_SystemdNormalizesUnits(t *testing.T) {
	db := newSourcesTestDB(t)
	raw := []byte(`{"type":"systemd","scope":"agent","node_id":"homelab-01","name":"systemd","config":{"units":[" nginx ","nginx.service","","redis"]},"enabled":true}`)
	req := httptest.NewRequest(http.MethodPost, "/api/admin/sources", bytes.NewReader(raw))
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	handlers.CreateSource(db)(w, req)
	require.Equal(t, http.StatusCreated, w.Code)

	var inst models.DataSourceInstance
	require.NoError(t, db.Where("type = ? AND node_id = ?", "systemd", "homelab-01").First(&inst).Error)
	require.JSONEq(t, `{"units":["nginx.service","redis.service"]}`, inst.Config)
}

func TestCreateSource_WebhookRequiresStringSecret(t *testing.T) {
	db := newSourcesTestDB(t)
	raw := []byte(`{"type":"webhook_uptime_kuma","scope":"server","name":"UK","config":{"secret":123},"enabled":true}`)
	req := httptest.NewRequest(http.MethodPost, "/api/admin/sources", bytes.NewReader(raw))
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	handlers.CreateSource(db)(w, req)
	require.Equal(t, http.StatusBadRequest, w.Code)
	require.Contains(t, w.Body.String(), "secret must be a non-empty string")
}

func TestCreateSource_WebhookRequiresNonEmptySecret(t *testing.T) {
	db := newSourcesTestDB(t)
	raw := []byte(`{"type":"webhook_uptime_kuma","scope":"server","name":"UK","config":{"secret":"   "},"enabled":true}`)
	req := httptest.NewRequest(http.MethodPost, "/api/admin/sources", bytes.NewReader(raw))
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	handlers.CreateSource(db)(w, req)
	require.Equal(t, http.StatusBadRequest, w.Code)
	require.Contains(t, w.Body.String(), "secret must be a non-empty string")
}

func TestCreateSource_KomodoValid(t *testing.T) {
	db := newSourcesTestDB(t)
	body, _ := json.Marshal(map[string]any{
		"type":  "webhook_komodo",
		"scope": "server",
		"name":  "Komodo Prod",
		"config": map[string]any{
			"secret":        "mysecret",
			"allowed_types": []string{"BuildFailed", "ProcedureCompleted"},
			"node_map":      map[string]any{"prod-komodo": "prod-1"},
		},
		"enabled": true,
	})
	req := httptest.NewRequest(http.MethodPost, "/api/admin/sources", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	handlers.CreateSource(db)(w, req)
	require.Equal(t, http.StatusCreated, w.Code)
}

func TestCreateSource_KomodoMissingAllowedTypes(t *testing.T) {
	db := newSourcesTestDB(t)
	body, _ := json.Marshal(map[string]any{
		"type": "webhook_komodo", "scope": "server", "name": "k",
		"config": map[string]any{"secret": "s"},
	})
	req := httptest.NewRequest(http.MethodPost, "/api/admin/sources", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	handlers.CreateSource(db)(w, req)
	require.Equal(t, http.StatusBadRequest, w.Code)
}

func TestCreateSource_KomodoEmptyAllowedTypes(t *testing.T) {
	db := newSourcesTestDB(t)
	body, _ := json.Marshal(map[string]any{
		"type": "webhook_komodo", "scope": "server", "name": "k",
		"config": map[string]any{"secret": "s", "allowed_types": []string{}},
	})
	req := httptest.NewRequest(http.MethodPost, "/api/admin/sources", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	handlers.CreateSource(db)(w, req)
	require.Equal(t, http.StatusBadRequest, w.Code)
}

func TestCreateSource_KomodoInvalidNodeMap(t *testing.T) {
	db := newSourcesTestDB(t)
	body, _ := json.Marshal(map[string]any{
		"type": "webhook_komodo", "scope": "server", "name": "k",
		"config": map[string]any{
			"secret":        "s",
			"allowed_types": []string{"BuildFailed"},
			"node_map":      map[string]any{"prod": ""},
		},
	})
	req := httptest.NewRequest(http.MethodPost, "/api/admin/sources", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	handlers.CreateSource(db)(w, req)
	require.Equal(t, http.StatusBadRequest, w.Code)
}

func TestCreateSource_KomodoNonSingleton(t *testing.T) {
	db := newSourcesTestDB(t)
	cfg := map[string]any{
		"secret":        "s",
		"allowed_types": []string{"BuildFailed"},
	}
	for i, name := range []string{"Komodo Prod", "Komodo Staging"} {
		body, _ := json.Marshal(map[string]any{
			"type": "webhook_komodo", "scope": "server", "name": name, "config": cfg, "enabled": true,
		})
		req := httptest.NewRequest(http.MethodPost, "/api/admin/sources", bytes.NewReader(body))
		req.Header.Set("Content-Type", "application/json")
		w := httptest.NewRecorder()
		handlers.CreateSource(db)(w, req)
		require.Equalf(t, http.StatusCreated, w.Code, "instance %d should be created", i+1)
	}
	var count int64
	require.NoError(t, db.Model(&models.DataSourceInstance{}).Where("type = ?", "webhook_komodo").Count(&count).Error)
	require.Equal(t, int64(2), count)
}

func TestUpdateSource_FileWatcherEmptyPartialConfigPreservesRedactSecrets(t *testing.T) {
	db := newSourcesTestDB(t)
	nodeName := "homelab-01"
	inst := models.DataSourceInstance{
		ID: "testid", Type: "filewatcher", Scope: "agent", NodeID: &nodeName,
		Name: "watcher", Config: `{"redact_secrets":true}`, Enabled: true,
	}
	require.NoError(t, db.Create(&inst).Error)

	r := chi.NewRouter()
	r.Put("/api/admin/sources/{id}", handlers.UpdateSource(db))
	req := httptest.NewRequest(http.MethodPut, "/api/admin/sources/testid", bytes.NewReader([]byte(`{"config":{}}`)))
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	r.ServeHTTP(w, req)
	require.Equal(t, http.StatusOK, w.Code)

	var updated models.DataSourceInstance
	require.NoError(t, db.First(&updated, "id = ?", "testid").Error)
	var cfg map[string]any
	require.NoError(t, json.Unmarshal([]byte(updated.Config), &cfg))
	require.Equal(t, true, cfg["redact_secrets"])
}

func TestUpdateSource_FileWatcherRequiresBooleanRedactSecrets(t *testing.T) {
	db := newSourcesTestDB(t)
	nodeName := "homelab-01"
	inst := models.DataSourceInstance{
		ID: "testid", Type: "filewatcher", Scope: "agent", NodeID: &nodeName,
		Name: "watcher", Config: `{"redact_secrets":true}`, Enabled: true,
	}
	require.NoError(t, db.Create(&inst).Error)

	r := chi.NewRouter()
	r.Put("/api/admin/sources/{id}", handlers.UpdateSource(db))
	req := httptest.NewRequest(http.MethodPut, "/api/admin/sources/testid", bytes.NewReader([]byte(`{"config":{"redact_secrets":"false"}}`)))
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	r.ServeHTTP(w, req)
	require.Equal(t, http.StatusBadRequest, w.Code)
	require.Contains(t, w.Body.String(), "redact_secrets must be a boolean")
}

func TestListSourceTypes(t *testing.T) {
	req := httptest.NewRequest(http.MethodGet, "/api/admin/sources/types", nil)
	w := httptest.NewRecorder()
	handlers.ListSourceTypes()(w, req)
	require.Equal(t, http.StatusOK, w.Code)
	var types []map[string]any
	require.NoError(t, json.Unmarshal(w.Body.Bytes(), &types))
	require.NotEmpty(t, types)
	found := map[string]bool{}
	for _, typ := range types {
		rawType, ok := typ["type"]
		require.True(t, ok, "missing type field")
		sourceType, ok := rawType.(string)
		require.True(t, ok, "type field must be a string")
		found[sourceType] = true
	}
	for _, expected := range []string{"systemd", "filewatcher", "webhook_uptime_kuma", "webhook_watchtower"} {
		require.True(t, found[expected], "missing type: "+expected)
	}
	require.True(t, found["docker"], "missing type: docker")
	require.GreaterOrEqual(t, len(found), 5)
}