Feature Toggle Management: allow editing PublicPreview toggles (#81562)
* Feature Toggle Management: allow editing PublicPreview toggles * lint * fix a bunch of tests * tests are passing * add permissions unit tests back * fix display * close dialog after submit * use reload method after submit * make local development easier * always show editing alert in the UI * fix readme --------- Co-authored-by: Michael Mandrus <michael.mandrus@grafana.com>
This commit is contained in:
co-authored by
Michael Mandrus
parent
f60b5ecec4
commit
42d6e176bc
@@ -0,0 +1,460 @@
|
||||
package featuretoggle
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/api/response"
|
||||
"github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/infra/appcontext"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func TestGetFeatureToggles(t *testing.T) {
|
||||
t.Run("fails without adequate permissions", func(t *testing.T) {
|
||||
features := featuremgmt.WithFeatureManager(setting.FeatureMgmtSettings{}, []*featuremgmt.FeatureFlag{{
|
||||
// Add this here to ensure the feature works as expected during tests
|
||||
Name: featuremgmt.FlagFeatureToggleAdminPage,
|
||||
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
||||
}})
|
||||
|
||||
b := NewFeatureFlagAPIBuilder(features, actest.FakeAccessControl{ExpectedEvaluate: false}, &setting.Cfg{})
|
||||
|
||||
callGetWith(t, b, http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
t.Run("should be able to get feature toggles", func(t *testing.T) {
|
||||
features := []*featuremgmt.FeatureFlag{
|
||||
{
|
||||
Name: "toggle1",
|
||||
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
||||
}, {
|
||||
Name: "toggle2",
|
||||
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
||||
},
|
||||
}
|
||||
disabled := []string{"toggle2"}
|
||||
|
||||
b := newTestAPIBuilder(t, features, disabled, setting.FeatureMgmtSettings{})
|
||||
result := callGetWith(t, b, http.StatusOK)
|
||||
assert.Len(t, result.Toggles, 2)
|
||||
t1, _ := findResult(t, result, "toggle1")
|
||||
assert.True(t, t1.Enabled)
|
||||
t2, _ := findResult(t, result, "toggle2")
|
||||
assert.False(t, t2.Enabled)
|
||||
})
|
||||
|
||||
t.Run("toggles hidden by config are not present in the response", func(t *testing.T) {
|
||||
features := []*featuremgmt.FeatureFlag{
|
||||
{
|
||||
Name: "toggle1",
|
||||
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
||||
}, {
|
||||
Name: "toggle2",
|
||||
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
||||
},
|
||||
}
|
||||
settings := setting.FeatureMgmtSettings{
|
||||
HiddenToggles: map[string]struct{}{"toggle1": {}},
|
||||
}
|
||||
|
||||
b := newTestAPIBuilder(t, features, []string{}, settings)
|
||||
result := callGetWith(t, b, http.StatusOK)
|
||||
|
||||
assert.Len(t, result.Toggles, 1)
|
||||
assert.Equal(t, "toggle2", result.Toggles[0].Name)
|
||||
})
|
||||
|
||||
t.Run("toggles that are read-only by config have the readOnly field set", func(t *testing.T) {
|
||||
features := []*featuremgmt.FeatureFlag{
|
||||
{
|
||||
Name: "toggle1",
|
||||
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
||||
}, {
|
||||
Name: "toggle2",
|
||||
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
||||
},
|
||||
}
|
||||
disabled := []string{"toggle2"}
|
||||
settings := setting.FeatureMgmtSettings{
|
||||
HiddenToggles: map[string]struct{}{"toggle1": {}},
|
||||
ReadOnlyToggles: map[string]struct{}{"toggle2": {}},
|
||||
AllowEditing: true,
|
||||
UpdateWebhook: "bogus",
|
||||
}
|
||||
|
||||
b := newTestAPIBuilder(t, features, disabled, settings)
|
||||
result := callGetWith(t, b, http.StatusOK)
|
||||
|
||||
assert.Len(t, result.Toggles, 1)
|
||||
assert.Equal(t, "toggle2", result.Toggles[0].Name)
|
||||
assert.False(t, result.Toggles[0].Writeable)
|
||||
})
|
||||
|
||||
t.Run("feature toggle defailts", func(t *testing.T) {
|
||||
features := []*featuremgmt.FeatureFlag{
|
||||
{
|
||||
Name: "toggle1",
|
||||
Stage: featuremgmt.FeatureStageUnknown,
|
||||
}, {
|
||||
Name: "toggle2",
|
||||
Stage: featuremgmt.FeatureStageExperimental,
|
||||
}, {
|
||||
Name: "toggle3",
|
||||
Stage: featuremgmt.FeatureStagePrivatePreview,
|
||||
}, {
|
||||
Name: "toggle4",
|
||||
Stage: featuremgmt.FeatureStagePublicPreview,
|
||||
AllowSelfServe: true,
|
||||
}, {
|
||||
Name: "toggle5",
|
||||
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
||||
AllowSelfServe: true,
|
||||
}, {
|
||||
Name: "toggle6",
|
||||
Stage: featuremgmt.FeatureStageDeprecated,
|
||||
AllowSelfServe: true,
|
||||
}, {
|
||||
Name: "toggle7",
|
||||
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
||||
AllowSelfServe: false,
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("unknown, experimental, and private preview toggles are hidden by default", func(t *testing.T) {
|
||||
b := newTestAPIBuilder(t, features, []string{}, setting.FeatureMgmtSettings{})
|
||||
result := callGetWith(t, b, http.StatusOK)
|
||||
|
||||
assert.Len(t, result.Toggles, 4)
|
||||
|
||||
_, ok := findResult(t, result, "toggle1")
|
||||
assert.False(t, ok)
|
||||
_, ok = findResult(t, result, "toggle2")
|
||||
assert.False(t, ok)
|
||||
_, ok = findResult(t, result, "toggle3")
|
||||
assert.False(t, ok)
|
||||
})
|
||||
|
||||
t.Run("only public preview and GA with AllowSelfServe are writeable", func(t *testing.T) {
|
||||
settings := setting.FeatureMgmtSettings{
|
||||
AllowEditing: true,
|
||||
UpdateWebhook: "bogus",
|
||||
}
|
||||
|
||||
b := newTestAPIBuilder(t, features, []string{}, settings)
|
||||
result := callGetWith(t, b, http.StatusOK)
|
||||
|
||||
t4, ok := findResult(t, result, "toggle4")
|
||||
assert.True(t, ok)
|
||||
assert.True(t, t4.Writeable)
|
||||
t5, ok := findResult(t, result, "toggle5")
|
||||
assert.True(t, ok)
|
||||
assert.True(t, t5.Writeable)
|
||||
t6, ok := findResult(t, result, "toggle6")
|
||||
assert.True(t, ok)
|
||||
assert.True(t, t6.Writeable)
|
||||
})
|
||||
|
||||
t.Run("all toggles are read-only when server is misconfigured", func(t *testing.T) {
|
||||
settings := setting.FeatureMgmtSettings{
|
||||
AllowEditing: false,
|
||||
UpdateWebhook: "",
|
||||
}
|
||||
b := newTestAPIBuilder(t, features, []string{}, settings)
|
||||
result := callGetWith(t, b, http.StatusOK)
|
||||
|
||||
assert.Len(t, result.Toggles, 4)
|
||||
|
||||
t4, ok := findResult(t, result, "toggle4")
|
||||
assert.True(t, ok)
|
||||
assert.False(t, t4.Writeable)
|
||||
t5, ok := findResult(t, result, "toggle5")
|
||||
assert.True(t, ok)
|
||||
assert.False(t, t5.Writeable)
|
||||
t6, ok := findResult(t, result, "toggle6")
|
||||
assert.True(t, ok)
|
||||
assert.False(t, t6.Writeable)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestSetFeatureToggles(t *testing.T) {
|
||||
t.Run("fails when the user doesn't have write permissions", func(t *testing.T) {
|
||||
s := setting.FeatureMgmtSettings{
|
||||
AllowEditing: true,
|
||||
UpdateWebhook: "random",
|
||||
}
|
||||
features := featuremgmt.WithFeatureManager(s, []*featuremgmt.FeatureFlag{{
|
||||
// Add this here to ensure the feature works as expected during tests
|
||||
Name: featuremgmt.FlagFeatureToggleAdminPage,
|
||||
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
||||
}})
|
||||
|
||||
b := NewFeatureFlagAPIBuilder(features, actest.FakeAccessControl{ExpectedEvaluate: false}, &setting.Cfg{})
|
||||
msg := callPatchWith(t, b, v0alpha1.ResolvedToggleState{}, http.StatusUnauthorized)
|
||||
assert.Equal(t, "missing write permission", msg)
|
||||
})
|
||||
|
||||
t.Run("fails when update toggle url is not set", func(t *testing.T) {
|
||||
s := setting.FeatureMgmtSettings{
|
||||
AllowEditing: true,
|
||||
}
|
||||
b := newTestAPIBuilder(t, nil, []string{}, s)
|
||||
msg := callPatchWith(t, b, v0alpha1.ResolvedToggleState{}, http.StatusForbidden)
|
||||
assert.Equal(t, "feature toggles are read-only", msg)
|
||||
})
|
||||
|
||||
t.Run("fails with non-existent toggle", func(t *testing.T) {
|
||||
features := []*featuremgmt.FeatureFlag{
|
||||
{
|
||||
Name: "toggle1",
|
||||
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
||||
}, {
|
||||
Name: "toggle2",
|
||||
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
||||
},
|
||||
}
|
||||
disabled := []string{"toggle2"}
|
||||
update := v0alpha1.ResolvedToggleState{
|
||||
Enabled: map[string]bool{
|
||||
"toggle3": true,
|
||||
},
|
||||
}
|
||||
|
||||
s := setting.FeatureMgmtSettings{
|
||||
AllowEditing: true,
|
||||
UpdateWebhook: "random",
|
||||
}
|
||||
b := newTestAPIBuilder(t, features, disabled, s)
|
||||
msg := callPatchWith(t, b, update, http.StatusBadRequest)
|
||||
assert.Equal(t, "invalid toggle passed in", msg)
|
||||
})
|
||||
|
||||
t.Run("fails with read-only toggles", func(t *testing.T) {
|
||||
features := []*featuremgmt.FeatureFlag{
|
||||
{
|
||||
Name: featuremgmt.FlagFeatureToggleAdminPage,
|
||||
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
||||
}, {
|
||||
Name: "toggle2",
|
||||
Stage: featuremgmt.FeatureStagePublicPreview,
|
||||
}, {
|
||||
Name: "toggle3",
|
||||
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
||||
},
|
||||
}
|
||||
disabled := []string{"toggle2", "toggle3"}
|
||||
|
||||
s := setting.FeatureMgmtSettings{
|
||||
AllowEditing: true,
|
||||
UpdateWebhook: "random",
|
||||
ReadOnlyToggles: map[string]struct{}{
|
||||
"toggle3": {},
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("because it is the feature toggle admin page toggle", func(t *testing.T) {
|
||||
update := v0alpha1.ResolvedToggleState{
|
||||
Enabled: map[string]bool{
|
||||
featuremgmt.FlagFeatureToggleAdminPage: true,
|
||||
},
|
||||
}
|
||||
b := newTestAPIBuilder(t, features, disabled, s)
|
||||
callPatchWith(t, b, update, http.StatusNotModified)
|
||||
})
|
||||
|
||||
t.Run("because it is not GA or Deprecated", func(t *testing.T) {
|
||||
update := v0alpha1.ResolvedToggleState{
|
||||
Enabled: map[string]bool{
|
||||
"toggle2": true,
|
||||
},
|
||||
}
|
||||
b := newTestAPIBuilder(t, features, disabled, s)
|
||||
msg := callPatchWith(t, b, update, http.StatusBadRequest)
|
||||
assert.Equal(t, "invalid toggle passed in", msg)
|
||||
})
|
||||
|
||||
t.Run("because it is configured to be read-only", func(t *testing.T) {
|
||||
update := v0alpha1.ResolvedToggleState{
|
||||
Enabled: map[string]bool{
|
||||
"toggle2": true,
|
||||
},
|
||||
}
|
||||
b := newTestAPIBuilder(t, features, disabled, s)
|
||||
msg := callPatchWith(t, b, update, http.StatusBadRequest)
|
||||
assert.Equal(t, "invalid toggle passed in", msg)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("when all conditions met", func(t *testing.T) {
|
||||
features := []*featuremgmt.FeatureFlag{
|
||||
{
|
||||
Name: featuremgmt.FlagFeatureToggleAdminPage,
|
||||
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
||||
}, {
|
||||
Name: "toggle2",
|
||||
Stage: featuremgmt.FeatureStagePublicPreview,
|
||||
}, {
|
||||
Name: "toggle3",
|
||||
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
||||
}, {
|
||||
Name: "toggle4",
|
||||
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
||||
AllowSelfServe: true,
|
||||
}, {
|
||||
Name: "toggle5",
|
||||
Stage: featuremgmt.FeatureStageDeprecated,
|
||||
AllowSelfServe: true,
|
||||
},
|
||||
}
|
||||
disabled := []string{"toggle2", "toggle3", "toggle4"}
|
||||
|
||||
s := setting.FeatureMgmtSettings{
|
||||
AllowEditing: true,
|
||||
UpdateWebhook: "random",
|
||||
UpdateWebhookToken: "token",
|
||||
ReadOnlyToggles: map[string]struct{}{
|
||||
"toggle3": {},
|
||||
},
|
||||
}
|
||||
|
||||
update := v0alpha1.ResolvedToggleState{
|
||||
Enabled: map[string]bool{
|
||||
"toggle4": true,
|
||||
"toggle5": false,
|
||||
},
|
||||
}
|
||||
t.Run("fail when webhook request is not successful", func(t *testing.T) {
|
||||
webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}))
|
||||
defer webhookServer.Close()
|
||||
s.UpdateWebhook = webhookServer.URL
|
||||
|
||||
b := newTestAPIBuilder(t, features, disabled, s)
|
||||
msg := callPatchWith(t, b, update, http.StatusInternalServerError)
|
||||
assert.Equal(t, "an error occurred while updating feeature toggles", msg)
|
||||
})
|
||||
|
||||
t.Run("succeed when webhook request is not successful but app is in dev mode", func(t *testing.T) {
|
||||
webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}))
|
||||
defer webhookServer.Close()
|
||||
s.UpdateWebhook = webhookServer.URL
|
||||
|
||||
b := newTestAPIBuilder(t, features, disabled, s)
|
||||
b.cfg.Env = setting.Dev
|
||||
callPatchWith(t, b, update, http.StatusOK)
|
||||
})
|
||||
|
||||
t.Run("succeed when webhook request is successful", func(t *testing.T) {
|
||||
webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "Bearer "+s.UpdateWebhookToken, r.Header.Get("Authorization"))
|
||||
|
||||
var req featuremgmt.FeatureToggleWebhookPayload
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
|
||||
|
||||
assert.Equal(t, "true", req.FeatureToggles["toggle4"])
|
||||
assert.Equal(t, "false", req.FeatureToggles["toggle5"])
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer webhookServer.Close()
|
||||
s.UpdateWebhook = webhookServer.URL
|
||||
|
||||
b := newTestAPIBuilder(t, features, disabled, s)
|
||||
msg := callPatchWith(t, b, update, http.StatusOK)
|
||||
assert.Equal(t, "feature toggles updated successfully", msg)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func findResult(t *testing.T, result v0alpha1.ResolvedToggleState, name string) (v0alpha1.ToggleStatus, bool) {
|
||||
t.Helper()
|
||||
|
||||
for _, t := range result.Toggles {
|
||||
if t.Name == name {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return v0alpha1.ToggleStatus{}, false
|
||||
}
|
||||
|
||||
func callGetWith(t *testing.T, b *FeatureFlagAPIBuilder, expectedCode int) v0alpha1.ResolvedToggleState {
|
||||
w := response.CreateNormalResponse(http.Header{}, []byte{}, 0)
|
||||
req := &http.Request{
|
||||
Method: "GET",
|
||||
Header: http.Header{},
|
||||
}
|
||||
req.Header.Add("content-type", "application/json")
|
||||
req = req.WithContext(appcontext.WithUser(req.Context(), &user.SignedInUser{}))
|
||||
b.handleCurrentStatus(w, req)
|
||||
|
||||
rts := v0alpha1.ResolvedToggleState{}
|
||||
require.NoError(t, json.Unmarshal(w.Body(), &rts))
|
||||
require.Equal(t, expectedCode, w.Status())
|
||||
|
||||
// Tests don't expect the feature toggle admin page feature to be present, so remove them from the resolved toggle state
|
||||
for i, t := range rts.Toggles {
|
||||
if t.Name == "featureToggleAdminPage" {
|
||||
rts.Toggles = append(rts.Toggles[0:i], rts.Toggles[i+1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
return rts
|
||||
}
|
||||
|
||||
func callPatchWith(t *testing.T, b *FeatureFlagAPIBuilder, update v0alpha1.ResolvedToggleState, expectedCode int) string {
|
||||
w := response.CreateNormalResponse(http.Header{}, []byte{}, 0)
|
||||
|
||||
body, err := json.Marshal(update)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := &http.Request{
|
||||
Method: "PATCH",
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
Header: http.Header{},
|
||||
}
|
||||
req.Header.Add("content-type", "application/json")
|
||||
req = req.WithContext(appcontext.WithUser(req.Context(), &user.SignedInUser{}))
|
||||
b.handleCurrentStatus(w, req)
|
||||
|
||||
require.NotNil(t, w.Body())
|
||||
require.Equal(t, expectedCode, w.Status())
|
||||
|
||||
// Extract the public facing message if this is an error
|
||||
if w.Status() > 399 {
|
||||
res := map[string]any{}
|
||||
require.NoError(t, json.Unmarshal(w.Body(), &res))
|
||||
|
||||
return res["message"].(string)
|
||||
}
|
||||
|
||||
return string(w.Body())
|
||||
}
|
||||
|
||||
func newTestAPIBuilder(
|
||||
t *testing.T,
|
||||
serverFeatures []*featuremgmt.FeatureFlag,
|
||||
disabled []string, // the flags that are disabled
|
||||
settings setting.FeatureMgmtSettings,
|
||||
) *FeatureFlagAPIBuilder {
|
||||
t.Helper()
|
||||
features := featuremgmt.WithFeatureManager(settings, append([]*featuremgmt.FeatureFlag{{
|
||||
// Add this here to ensure the feature works as expected during tests
|
||||
Name: featuremgmt.FlagFeatureToggleAdminPage,
|
||||
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
||||
}}, serverFeatures...), disabled...)
|
||||
|
||||
return NewFeatureFlagAPIBuilder(features, actest.FakeAccessControl{ExpectedEvaluate: true}, &setting.Cfg{})
|
||||
}
|
||||
Reference in New Issue
Block a user