Merge pull request 'feat: admin settings API — GET/PUT settings, test connectivity' (#104) from feat-admin-settings-api into feat-module-system
Reviewed-on: #104
This commit was merged in pull request #104.
This commit is contained in:
@@ -51,6 +51,7 @@ type Server struct {
|
|||||||
jobDefsDir string
|
jobDefsDir string
|
||||||
modules *modules.Registry
|
modules *modules.Registry
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
|
settings *db.SettingsRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer creates a new API server.
|
// NewServer creates a new API server.
|
||||||
@@ -77,6 +78,7 @@ func NewServer(
|
|||||||
itemFiles := db.NewItemFileRepository(database)
|
itemFiles := db.NewItemFileRepository(database)
|
||||||
dag := db.NewDAGRepository(database)
|
dag := db.NewDAGRepository(database)
|
||||||
jobs := db.NewJobRepository(database)
|
jobs := db.NewJobRepository(database)
|
||||||
|
settings := db.NewSettingsRepository(database)
|
||||||
seqStore := &dbSequenceStore{db: database, schemas: schemas}
|
seqStore := &dbSequenceStore{db: database, schemas: schemas}
|
||||||
partgen := partnum.NewGenerator(schemas, seqStore)
|
partgen := partnum.NewGenerator(schemas, seqStore)
|
||||||
|
|
||||||
@@ -103,6 +105,7 @@ func NewServer(
|
|||||||
jobDefsDir: jobDefsDir,
|
jobDefsDir: jobDefsDir,
|
||||||
modules: registry,
|
modules: registry,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
|
settings: settings,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -262,6 +262,15 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
|
|||||||
r.Use(server.RequireRole(auth.RoleEditor))
|
r.Use(server.RequireRole(auth.RoleEditor))
|
||||||
r.Post("/generate-part-number", server.HandleGeneratePartNumber)
|
r.Post("/generate-part-number", server.HandleGeneratePartNumber)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Admin settings (admin only)
|
||||||
|
r.Route("/admin/settings", func(r chi.Router) {
|
||||||
|
r.Use(server.RequireRole(auth.RoleAdmin))
|
||||||
|
r.Get("/", server.HandleGetAllSettings)
|
||||||
|
r.Get("/{module}", server.HandleGetModuleSettings)
|
||||||
|
r.Put("/{module}", server.HandleUpdateModuleSettings)
|
||||||
|
r.Post("/{module}/test", server.HandleTestModuleConnectivity)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Runner-facing API (runner token auth, not user auth)
|
// Runner-facing API (runner token auth, not user auth)
|
||||||
|
|||||||
316
internal/api/settings_handlers.go
Normal file
316
internal/api/settings_handlers.go
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/kindredsystems/silo/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleGetAllSettings returns the full config grouped by module with secrets redacted.
|
||||||
|
func (s *Server) HandleGetAllSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
resp := map[string]any{
|
||||||
|
"core": s.buildCoreSettings(),
|
||||||
|
"schemas": s.buildSchemasSettings(),
|
||||||
|
"storage": s.buildStorageSettings(r.Context()),
|
||||||
|
"database": s.buildDatabaseSettings(r.Context()),
|
||||||
|
"auth": s.buildAuthSettings(),
|
||||||
|
"projects": map[string]any{"enabled": s.modules.IsEnabled("projects")},
|
||||||
|
"audit": map[string]any{"enabled": s.modules.IsEnabled("audit")},
|
||||||
|
"odoo": s.buildOdooSettings(),
|
||||||
|
"freecad": s.buildFreecadSettings(),
|
||||||
|
"jobs": s.buildJobsSettings(),
|
||||||
|
"dag": map[string]any{"enabled": s.modules.IsEnabled("dag")},
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleGetModuleSettings returns settings for a single module.
|
||||||
|
func (s *Server) HandleGetModuleSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
module := chi.URLParam(r, "module")
|
||||||
|
|
||||||
|
var settings any
|
||||||
|
switch module {
|
||||||
|
case "core":
|
||||||
|
settings = s.buildCoreSettings()
|
||||||
|
case "schemas":
|
||||||
|
settings = s.buildSchemasSettings()
|
||||||
|
case "storage":
|
||||||
|
settings = s.buildStorageSettings(r.Context())
|
||||||
|
case "database":
|
||||||
|
settings = s.buildDatabaseSettings(r.Context())
|
||||||
|
case "auth":
|
||||||
|
settings = s.buildAuthSettings()
|
||||||
|
case "projects":
|
||||||
|
settings = map[string]any{"enabled": s.modules.IsEnabled("projects")}
|
||||||
|
case "audit":
|
||||||
|
settings = map[string]any{"enabled": s.modules.IsEnabled("audit")}
|
||||||
|
case "odoo":
|
||||||
|
settings = s.buildOdooSettings()
|
||||||
|
case "freecad":
|
||||||
|
settings = s.buildFreecadSettings()
|
||||||
|
case "jobs":
|
||||||
|
settings = s.buildJobsSettings()
|
||||||
|
case "dag":
|
||||||
|
settings = map[string]any{"enabled": s.modules.IsEnabled("dag")}
|
||||||
|
default:
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "Unknown module: "+module)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleUpdateModuleSettings handles module toggle and config overrides.
|
||||||
|
func (s *Server) HandleUpdateModuleSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
module := chi.URLParam(r, "module")
|
||||||
|
|
||||||
|
// Validate module exists
|
||||||
|
if s.modules.Get(module) == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "Unknown module: "+module)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := auth.UserFromContext(r.Context())
|
||||||
|
username := "system"
|
||||||
|
if user != nil {
|
||||||
|
username = user.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
var updated []string
|
||||||
|
restartRequired := false
|
||||||
|
|
||||||
|
// Handle module toggle
|
||||||
|
if enabledVal, ok := body["enabled"]; ok {
|
||||||
|
enabled, ok := enabledVal.(bool)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_value", "'enabled' must be a boolean")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.modules.SetEnabled(module, enabled); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "toggle_failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.settings.SetModuleState(r.Context(), module, enabled, username); err != nil {
|
||||||
|
s.logger.Error().Err(err).Str("module", module).Msg("failed to persist module state")
|
||||||
|
writeError(w, http.StatusInternalServerError, "persist_failed", "Failed to save module state")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updated = append(updated, module+".enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle config overrides (future use — persisted but not hot-reloaded)
|
||||||
|
for key, value := range body {
|
||||||
|
if key == "enabled" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fullKey := module + "." + key
|
||||||
|
if err := s.settings.SetOverride(r.Context(), fullKey, value, username); err != nil {
|
||||||
|
s.logger.Error().Err(err).Str("key", fullKey).Msg("failed to persist setting override")
|
||||||
|
writeError(w, http.StatusInternalServerError, "persist_failed", "Failed to save setting: "+key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updated = append(updated, fullKey)
|
||||||
|
|
||||||
|
// These namespaces require a restart to take effect
|
||||||
|
if strings.HasPrefix(fullKey, "database.") ||
|
||||||
|
strings.HasPrefix(fullKey, "storage.") ||
|
||||||
|
strings.HasPrefix(fullKey, "server.") ||
|
||||||
|
strings.HasPrefix(fullKey, "schemas.") {
|
||||||
|
restartRequired = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"updated": updated,
|
||||||
|
"restart_required": restartRequired,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Publish SSE event
|
||||||
|
s.broker.Publish("settings.changed", mustMarshal(map[string]any{
|
||||||
|
"module": module,
|
||||||
|
"changed_keys": updated,
|
||||||
|
"updated_by": username,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleTestModuleConnectivity tests external connectivity for a module.
|
||||||
|
func (s *Server) HandleTestModuleConnectivity(w http.ResponseWriter, r *http.Request) {
|
||||||
|
module := chi.URLParam(r, "module")
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
var success bool
|
||||||
|
var message string
|
||||||
|
|
||||||
|
switch module {
|
||||||
|
case "database":
|
||||||
|
if err := s.db.Pool().Ping(r.Context()); err != nil {
|
||||||
|
success = false
|
||||||
|
message = "Database ping failed: " + err.Error()
|
||||||
|
} else {
|
||||||
|
success = true
|
||||||
|
message = "Database connection OK"
|
||||||
|
}
|
||||||
|
case "storage":
|
||||||
|
if s.storage == nil {
|
||||||
|
success = false
|
||||||
|
message = "Storage not configured"
|
||||||
|
} else if err := s.storage.Ping(r.Context()); err != nil {
|
||||||
|
success = false
|
||||||
|
message = "Storage ping failed: " + err.Error()
|
||||||
|
} else {
|
||||||
|
success = true
|
||||||
|
message = "Storage connection OK"
|
||||||
|
}
|
||||||
|
case "auth", "odoo":
|
||||||
|
success = false
|
||||||
|
message = "Connectivity test not implemented for " + module
|
||||||
|
default:
|
||||||
|
writeError(w, http.StatusBadRequest, "not_testable", "No connectivity test available for module: "+module)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
latency := time.Since(start).Milliseconds()
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"success": success,
|
||||||
|
"message": message,
|
||||||
|
"latency_ms": latency,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- build helpers (read config, redact secrets) ---
|
||||||
|
|
||||||
|
func redact(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "****"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) buildCoreSettings() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"enabled": true,
|
||||||
|
"host": s.cfg.Server.Host,
|
||||||
|
"port": s.cfg.Server.Port,
|
||||||
|
"base_url": s.cfg.Server.BaseURL,
|
||||||
|
"readonly": s.cfg.Server.ReadOnly,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) buildSchemasSettings() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"enabled": true,
|
||||||
|
"directory": s.cfg.Schemas.Directory,
|
||||||
|
"default": s.cfg.Schemas.Default,
|
||||||
|
"count": len(s.schemas),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) buildStorageSettings(ctx context.Context) map[string]any {
|
||||||
|
result := map[string]any{
|
||||||
|
"enabled": true,
|
||||||
|
"endpoint": s.cfg.Storage.Endpoint,
|
||||||
|
"bucket": s.cfg.Storage.Bucket,
|
||||||
|
"use_ssl": s.cfg.Storage.UseSSL,
|
||||||
|
"region": s.cfg.Storage.Region,
|
||||||
|
}
|
||||||
|
if s.storage != nil {
|
||||||
|
if err := s.storage.Ping(ctx); err != nil {
|
||||||
|
result["status"] = "unavailable"
|
||||||
|
} else {
|
||||||
|
result["status"] = "ok"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result["status"] = "not_configured"
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) buildDatabaseSettings(ctx context.Context) map[string]any {
|
||||||
|
result := map[string]any{
|
||||||
|
"enabled": true,
|
||||||
|
"host": s.cfg.Database.Host,
|
||||||
|
"port": s.cfg.Database.Port,
|
||||||
|
"name": s.cfg.Database.Name,
|
||||||
|
"user": s.cfg.Database.User,
|
||||||
|
"password": redact(s.cfg.Database.Password),
|
||||||
|
"sslmode": s.cfg.Database.SSLMode,
|
||||||
|
"max_connections": s.cfg.Database.MaxConnections,
|
||||||
|
}
|
||||||
|
if err := s.db.Pool().Ping(ctx); err != nil {
|
||||||
|
result["status"] = "unavailable"
|
||||||
|
} else {
|
||||||
|
result["status"] = "ok"
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) buildAuthSettings() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"enabled": s.modules.IsEnabled("auth"),
|
||||||
|
"session_secret": redact(s.cfg.Auth.SessionSecret),
|
||||||
|
"local": map[string]any{
|
||||||
|
"enabled": s.cfg.Auth.Local.Enabled,
|
||||||
|
"default_admin_username": s.cfg.Auth.Local.DefaultAdminUsername,
|
||||||
|
"default_admin_password": redact(s.cfg.Auth.Local.DefaultAdminPassword),
|
||||||
|
},
|
||||||
|
"ldap": map[string]any{
|
||||||
|
"enabled": s.cfg.Auth.LDAP.Enabled,
|
||||||
|
"url": s.cfg.Auth.LDAP.URL,
|
||||||
|
"base_dn": s.cfg.Auth.LDAP.BaseDN,
|
||||||
|
"bind_dn": s.cfg.Auth.LDAP.BindDN,
|
||||||
|
"bind_password": redact(s.cfg.Auth.LDAP.BindPassword),
|
||||||
|
},
|
||||||
|
"oidc": map[string]any{
|
||||||
|
"enabled": s.cfg.Auth.OIDC.Enabled,
|
||||||
|
"issuer_url": s.cfg.Auth.OIDC.IssuerURL,
|
||||||
|
"client_id": s.cfg.Auth.OIDC.ClientID,
|
||||||
|
"client_secret": redact(s.cfg.Auth.OIDC.ClientSecret),
|
||||||
|
"redirect_url": s.cfg.Auth.OIDC.RedirectURL,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) buildOdooSettings() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"enabled": s.modules.IsEnabled("odoo"),
|
||||||
|
"url": s.cfg.Odoo.URL,
|
||||||
|
"database": s.cfg.Odoo.Database,
|
||||||
|
"username": s.cfg.Odoo.Username,
|
||||||
|
"api_key": redact(s.cfg.Odoo.APIKey),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) buildFreecadSettings() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"enabled": s.modules.IsEnabled("freecad"),
|
||||||
|
"uri_scheme": s.cfg.FreeCAD.URIScheme,
|
||||||
|
"executable": s.cfg.FreeCAD.Executable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) buildJobsSettings() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"enabled": s.modules.IsEnabled("jobs"),
|
||||||
|
"directory": s.cfg.Jobs.Directory,
|
||||||
|
"runner_timeout": s.cfg.Jobs.RunnerTimeout,
|
||||||
|
"job_timeout_check": s.cfg.Jobs.JobTimeoutCheck,
|
||||||
|
"default_priority": s.cfg.Jobs.DefaultPriority,
|
||||||
|
"definitions_count": len(s.jobDefs),
|
||||||
|
}
|
||||||
|
}
|
||||||
285
internal/api/settings_handlers_test.go
Normal file
285
internal/api/settings_handlers_test.go
Normal file
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
105
internal/db/settings.go
Normal file
105
internal/db/settings.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SettingsRepository provides access to module_state and settings_overrides tables.
|
||||||
|
type SettingsRepository struct {
|
||||||
|
db *DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSettingsRepository creates a new SettingsRepository.
|
||||||
|
func NewSettingsRepository(db *DB) *SettingsRepository {
|
||||||
|
return &SettingsRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetModuleStates returns all module enabled/disabled states from the database.
|
||||||
|
func (r *SettingsRepository) GetModuleStates(ctx context.Context) (map[string]bool, error) {
|
||||||
|
rows, err := r.db.pool.Query(ctx,
|
||||||
|
`SELECT module_id, enabled FROM module_state`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying module states: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
states := make(map[string]bool)
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
var enabled bool
|
||||||
|
if err := rows.Scan(&id, &enabled); err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning module state: %w", err)
|
||||||
|
}
|
||||||
|
states[id] = enabled
|
||||||
|
}
|
||||||
|
return states, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetModuleState persists a module's enabled state. Uses upsert semantics.
|
||||||
|
func (r *SettingsRepository) SetModuleState(ctx context.Context, moduleID string, enabled bool, updatedBy string) error {
|
||||||
|
_, err := r.db.pool.Exec(ctx,
|
||||||
|
`INSERT INTO module_state (module_id, enabled, updated_by, updated_at)
|
||||||
|
VALUES ($1, $2, $3, now())
|
||||||
|
ON CONFLICT (module_id) DO UPDATE
|
||||||
|
SET enabled = EXCLUDED.enabled,
|
||||||
|
updated_by = EXCLUDED.updated_by,
|
||||||
|
updated_at = now()`,
|
||||||
|
moduleID, enabled, updatedBy)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("setting module state: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOverrides returns all settings overrides from the database.
|
||||||
|
func (r *SettingsRepository) GetOverrides(ctx context.Context) (map[string]json.RawMessage, error) {
|
||||||
|
rows, err := r.db.pool.Query(ctx,
|
||||||
|
`SELECT key, value FROM settings_overrides`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying settings overrides: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
overrides := make(map[string]json.RawMessage)
|
||||||
|
for rows.Next() {
|
||||||
|
var key string
|
||||||
|
var value json.RawMessage
|
||||||
|
if err := rows.Scan(&key, &value); err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning settings override: %w", err)
|
||||||
|
}
|
||||||
|
overrides[key] = value
|
||||||
|
}
|
||||||
|
return overrides, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetOverride persists a settings override. Uses upsert semantics.
|
||||||
|
func (r *SettingsRepository) SetOverride(ctx context.Context, key string, value any, updatedBy string) error {
|
||||||
|
jsonVal, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshaling override value: %w", err)
|
||||||
|
}
|
||||||
|
_, err = r.db.pool.Exec(ctx,
|
||||||
|
`INSERT INTO settings_overrides (key, value, updated_by, updated_at)
|
||||||
|
VALUES ($1, $2, $3, now())
|
||||||
|
ON CONFLICT (key) DO UPDATE
|
||||||
|
SET value = EXCLUDED.value,
|
||||||
|
updated_by = EXCLUDED.updated_by,
|
||||||
|
updated_at = now()`,
|
||||||
|
key, jsonVal, updatedBy)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("setting override: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOverride removes a settings override.
|
||||||
|
func (r *SettingsRepository) DeleteOverride(ctx context.Context, key string) error {
|
||||||
|
_, err := r.db.pool.Exec(ctx,
|
||||||
|
`DELETE FROM settings_overrides WHERE key = $1`, key)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deleting override: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user