diff --git a/cmd/api/handlers/environments_crud.go b/cmd/api/handlers/environments_crud.go index c0b5e260..d48d0aa7 100644 --- a/cmd/api/handlers/environments_crud.go +++ b/cmd/api/handlers/environments_crud.go @@ -483,6 +483,26 @@ func (h *HandlersApi) EnvironmentConfigPatchHandler(w http.ResponseWriter, r *ht return } } + // Recompose the assembled `configuration` blob from the (possibly just- + // updated) parts. The Update* calls above only write their own column; + // without this recompose the env's `configuration` field — which is what + // GET .../configuration/assembled returns and what agents receive on + // their next /config refresh — stays at the pre-patch value, so edits made + // here never show up on the enroll page's Configuration tab. Flags is not + // part of the composed osquery config, so a flags-only patch skips it. + composedChanged := false + for _, k := range []string{"options", "schedule", "packs", "decorators", "atc"} { + if _, ok := normalized[k]; ok { + composedChanged = true + break + } + } + if composedChanged { + if err := h.Envs.RefreshConfiguration(envVar); err != nil { + apiErrorResponse(w, "error refreshing configuration", http.StatusInternalServerError, err) + return + } + } h.AuditLog.ConfAction(ctx[ctxUser], "config patch on env "+env.Name, strings.Split(r.RemoteAddr, ":")[0], env.ID) updated, _ := h.Envs.Get(envVar) resp := types.EnvConfigResponse{ diff --git a/pkg/environments/config_refresh_test.go b/pkg/environments/config_refresh_test.go new file mode 100644 index 00000000..4f96002f --- /dev/null +++ b/pkg/environments/config_refresh_test.go @@ -0,0 +1,46 @@ +package environments + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestRefreshConfigurationRecomposesAfterPartUpdate locks the contract the +// environment-config PATCH handler relies on: UpdateSchedule (and the other +// Update* part writers) only persist their own column — they do NOT recompose +// the assembled `configuration` blob that GET .../configuration/assembled +// returns and that agents receive on /config refresh. RefreshConfiguration +// is what folds the parts back together. Without the handler calling it, a +// schedule edit saved from the SPA's Configuration → Schedule tab never shows +// up on the enroll page's Configuration tab. +func TestRefreshConfigurationRecomposesAfterPartUpdate(t *testing.T) { + db := setupTestDB(t) + envs := CreateEnvironment(db) + + env := envs.Empty("dev", "dev.example.com") + require.NoError(t, envs.Create(&env)) + + schedule := `{"uptime":{"query":"SELECT * FROM uptime;","interval":60}}` + require.NoError(t, envs.UpdateSchedule(env.Name, schedule)) + + // The bug: the part is saved, but the composed blob is still the empty + // placeholder until RefreshConfiguration runs. + before, err := envs.Get(env.Name) + require.NoError(t, err) + assert.Equal(t, "{}", before.Configuration, "UpdateSchedule must not recompose configuration on its own") + + // The fix: RefreshConfiguration folds the updated schedule (and the other + // parts) into the composed blob. + require.NoError(t, envs.RefreshConfiguration(env.Name)) + + after, err := envs.Get(env.Name) + require.NoError(t, err) + assert.NotEqual(t, "{}", after.Configuration, "RefreshConfiguration should recompose the blob") + assert.True(t, strings.Contains(after.Configuration, "uptime"), + "assembled configuration should contain the scheduled query, got %s", after.Configuration) + assert.True(t, strings.Contains(after.Configuration, "SELECT * FROM uptime;"), + "assembled configuration should contain the query SQL, got %s", after.Configuration) +}