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{ Backend: "filesystem", Filesystem: config.FilesystemConfig{RootDir: "/tmp/silo-test"}, }, 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, nil, // workflows ) } 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()) } }