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) }