diff --git a/internal/api/settings_handlers_test.go b/internal/api/settings_handlers_test.go new file mode 100644 index 0000000..716afca --- /dev/null +++ b/internal/api/settings_handlers_test.go @@ -0,0 +1,285 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/kindredsystems/silo/internal/auth" + "github.com/kindredsystems/silo/internal/config" + "github.com/kindredsystems/silo/internal/db" + "github.com/kindredsystems/silo/internal/modules" + "github.com/kindredsystems/silo/internal/schema" + "github.com/kindredsystems/silo/internal/testutil" + "github.com/rs/zerolog" +) + +func newSettingsTestServer(t *testing.T) *Server { + t.Helper() + pool := testutil.MustConnectTestPool(t) + database := db.NewFromPool(pool) + broker := NewBroker(zerolog.Nop()) + state := NewServerState(zerolog.Nop(), nil, broker) + cfg := &config.Config{ + Server: config.ServerConfig{Host: "0.0.0.0", Port: 8080}, + Database: config.DatabaseConfig{ + Host: "localhost", Port: 5432, Name: "silo_test", + User: "silo", Password: "secret", SSLMode: "disable", + MaxConnections: 10, + }, + Storage: config.StorageConfig{ + Endpoint: "minio:9000", Bucket: "silo", Region: "us-east-1", + AccessKey: "minioadmin", SecretKey: "miniosecret", + }, + Schemas: config.SchemasConfig{Directory: "/etc/silo/schemas", Default: "kindred-rd"}, + Auth: config.AuthConfig{ + SessionSecret: "supersecret", + Local: config.LocalAuth{Enabled: true, DefaultAdminUsername: "admin", DefaultAdminPassword: "changeme"}, + LDAP: config.LDAPAuth{Enabled: false, BindPassword: "ldapsecret"}, + OIDC: config.OIDCAuth{Enabled: false, ClientSecret: "oidcsecret"}, + }, + FreeCAD: config.FreeCADConfig{URIScheme: "silo"}, + Odoo: config.OdooConfig{URL: "https://odoo.example.com", APIKey: "odoo-api-key"}, + Jobs: config.JobsConfig{Directory: "/etc/silo/jobdefs", RunnerTimeout: 90, JobTimeoutCheck: 30, DefaultPriority: 100}, + } + return NewServer( + zerolog.Nop(), + database, + map[string]*schema.Schema{"test": {Name: "test"}}, + cfg.Schemas.Directory, + nil, // storage + nil, // authService + nil, // sessionManager + nil, // oidcBackend + nil, // authConfig + broker, + state, + nil, // jobDefs + "", // jobDefsDir + modules.NewRegistry(), // modules + cfg, + ) +} + +func newSettingsRouter(s *Server) http.Handler { + r := chi.NewRouter() + r.Route("/api/admin/settings", func(r chi.Router) { + r.Get("/", s.HandleGetAllSettings) + r.Get("/{module}", s.HandleGetModuleSettings) + r.Put("/{module}", s.HandleUpdateModuleSettings) + r.Post("/{module}/test", s.HandleTestModuleConnectivity) + }) + return r +} + +func adminSettingsRequest(r *http.Request) *http.Request { + u := &auth.User{ + ID: "admin-id", + Username: "testadmin", + Role: auth.RoleAdmin, + } + return r.WithContext(auth.ContextWithUser(r.Context(), u)) +} + +func viewerSettingsRequest(r *http.Request) *http.Request { + u := &auth.User{ + ID: "viewer-id", + Username: "testviewer", + Role: auth.RoleViewer, + } + return r.WithContext(auth.ContextWithUser(r.Context(), u)) +} + +func TestGetAllSettings(t *testing.T) { + s := newSettingsTestServer(t) + router := newSettingsRouter(s) + + req := adminSettingsRequest(httptest.NewRequest("GET", "/api/admin/settings", nil)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decoding: %v", err) + } + + // Verify all module keys present + expectedModules := []string{"core", "schemas", "storage", "database", "auth", "projects", "audit", "odoo", "freecad", "jobs", "dag"} + for _, mod := range expectedModules { + if _, ok := resp[mod]; !ok { + t.Errorf("missing module key: %s", mod) + } + } + + // Verify secrets are redacted + dbSettings, _ := resp["database"].(map[string]any) + if dbSettings["password"] != "****" { + t.Errorf("database password not redacted: got %v", dbSettings["password"]) + } + + authSettings, _ := resp["auth"].(map[string]any) + if authSettings["session_secret"] != "****" { + t.Errorf("session_secret not redacted: got %v", authSettings["session_secret"]) + } + + ldap, _ := authSettings["ldap"].(map[string]any) + if ldap["bind_password"] != "****" { + t.Errorf("ldap bind_password not redacted: got %v", ldap["bind_password"]) + } + + oidc, _ := authSettings["oidc"].(map[string]any) + if oidc["client_secret"] != "****" { + t.Errorf("oidc client_secret not redacted: got %v", oidc["client_secret"]) + } + + odoo, _ := resp["odoo"].(map[string]any) + if odoo["api_key"] != "****" { + t.Errorf("odoo api_key not redacted: got %v", odoo["api_key"]) + } +} + +func TestGetModuleSettings(t *testing.T) { + s := newSettingsTestServer(t) + router := newSettingsRouter(s) + + req := adminSettingsRequest(httptest.NewRequest("GET", "/api/admin/settings/jobs", nil)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decoding: %v", err) + } + + if resp["directory"] != "/etc/silo/jobdefs" { + t.Errorf("jobs directory: got %v, want /etc/silo/jobdefs", resp["directory"]) + } + if resp["runner_timeout"] != float64(90) { + t.Errorf("runner_timeout: got %v, want 90", resp["runner_timeout"]) + } +} + +func TestGetModuleSettings_Unknown(t *testing.T) { + s := newSettingsTestServer(t) + router := newSettingsRouter(s) + + req := adminSettingsRequest(httptest.NewRequest("GET", "/api/admin/settings/nonexistent", nil)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status: got %d, want %d", w.Code, http.StatusNotFound) + } +} + +func TestToggleModule(t *testing.T) { + s := newSettingsTestServer(t) + router := newSettingsRouter(s) + + // Projects is enabled by default; disable it + body := `{"enabled": false}` + req := adminSettingsRequest(httptest.NewRequest("PUT", "/api/admin/settings/projects", strings.NewReader(body))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decoding: %v", err) + } + + updated, _ := resp["updated"].([]any) + if len(updated) != 1 || updated[0] != "projects.enabled" { + t.Errorf("updated: got %v, want [projects.enabled]", updated) + } + + // Verify registry state + if s.modules.IsEnabled("projects") { + t.Error("projects should be disabled after toggle") + } +} + +func TestToggleModule_DependencyError(t *testing.T) { + s := newSettingsTestServer(t) + router := newSettingsRouter(s) + + // DAG depends on Jobs. Jobs is disabled by default. + // Enabling DAG without Jobs should fail. + body := `{"enabled": true}` + req := adminSettingsRequest(httptest.NewRequest("PUT", "/api/admin/settings/dag", strings.NewReader(body))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status: got %d, want %d; body: %s", w.Code, http.StatusBadRequest, w.Body.String()) + } +} + +func TestToggleRequiredModule(t *testing.T) { + s := newSettingsTestServer(t) + router := newSettingsRouter(s) + + body := `{"enabled": false}` + req := adminSettingsRequest(httptest.NewRequest("PUT", "/api/admin/settings/core", strings.NewReader(body))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status: got %d, want %d; body: %s", w.Code, http.StatusBadRequest, w.Body.String()) + } +} + +func TestTestConnectivity_Database(t *testing.T) { + s := newSettingsTestServer(t) + router := newSettingsRouter(s) + + req := adminSettingsRequest(httptest.NewRequest("POST", "/api/admin/settings/database/test", nil)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decoding: %v", err) + } + + if resp["success"] != true { + t.Errorf("expected success=true, got %v; message: %v", resp["success"], resp["message"]) + } + if resp["latency_ms"] == nil { + t.Error("expected latency_ms in response") + } +} + +func TestTestConnectivity_NotTestable(t *testing.T) { + s := newSettingsTestServer(t) + router := newSettingsRouter(s) + + req := adminSettingsRequest(httptest.NewRequest("POST", "/api/admin/settings/core/test", nil)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status: got %d, want %d; body: %s", w.Code, http.StatusBadRequest, w.Body.String()) + } +}