mirror of
https://github.com/grafana/grafana.git
synced 2024-12-27 09:21:35 -06:00
7e1b45ba31
* Feature Toggles API: Trigger webhook call when updating * update status code error check * lint - handle Close() error * Rename update webhook config * fix tests
492 lines
15 KiB
Go
492 lines
15 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/grafana/grafana/pkg/infra/log"
|
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
|
"github.com/grafana/grafana/pkg/services/org/orgtest"
|
|
"github.com/grafana/grafana/pkg/services/user"
|
|
"github.com/grafana/grafana/pkg/services/user/usertest"
|
|
"github.com/grafana/grafana/pkg/setting"
|
|
"github.com/grafana/grafana/pkg/web/webtest"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestGetFeatureToggles(t *testing.T) {
|
|
readPermissions := []accesscontrol.Permission{{Action: accesscontrol.ActionFeatureManagementRead}}
|
|
|
|
t.Run("should not be able to get feature toggles without permissions", func(t *testing.T) {
|
|
result := runGetScenario(t, []*featuremgmt.FeatureFlag{}, setting.FeatureMgmtSettings{}, []accesscontrol.Permission{}, http.StatusForbidden)
|
|
assert.Len(t, result, 0)
|
|
})
|
|
|
|
t.Run("should be able to get feature toggles with correct permissions", func(t *testing.T) {
|
|
features := []*featuremgmt.FeatureFlag{
|
|
{
|
|
Name: "toggle1",
|
|
Enabled: true,
|
|
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
|
}, {
|
|
Name: "toggle2",
|
|
Enabled: false,
|
|
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
|
},
|
|
}
|
|
|
|
result := runGetScenario(t, features, setting.FeatureMgmtSettings{}, readPermissions, http.StatusOK)
|
|
assert.Len(t, result, 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",
|
|
Enabled: true,
|
|
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
|
}, {
|
|
Name: "toggle2",
|
|
Enabled: false,
|
|
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
|
},
|
|
}
|
|
settings := setting.FeatureMgmtSettings{
|
|
HiddenToggles: map[string]struct{}{"toggle1": {}},
|
|
}
|
|
|
|
result := runGetScenario(t, features, settings, readPermissions, http.StatusOK)
|
|
assert.Len(t, result, 1)
|
|
assert.Equal(t, "toggle2", result[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",
|
|
Enabled: true,
|
|
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
|
}, {
|
|
Name: "toggle2",
|
|
Enabled: false,
|
|
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
|
},
|
|
}
|
|
settings := setting.FeatureMgmtSettings{
|
|
HiddenToggles: map[string]struct{}{"toggle1": {}},
|
|
ReadOnlyToggles: map[string]struct{}{"toggle2": {}},
|
|
AllowEditing: true,
|
|
UpdateWebhook: "bogus",
|
|
}
|
|
|
|
result := runGetScenario(t, features, settings, readPermissions, http.StatusOK)
|
|
assert.Len(t, result, 1)
|
|
assert.Equal(t, "toggle2", result[0].Name)
|
|
assert.True(t, result[0].ReadOnly)
|
|
})
|
|
|
|
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,
|
|
}, {
|
|
Name: "toggle5",
|
|
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
|
}, {
|
|
Name: "toggle6",
|
|
Stage: featuremgmt.FeatureStageDeprecated,
|
|
},
|
|
}
|
|
|
|
t.Run("unknown, experimental, and private preview toggles are hidden by default", func(t *testing.T) {
|
|
result := runGetScenario(t, features, setting.FeatureMgmtSettings{}, readPermissions, http.StatusOK)
|
|
assert.Len(t, result, 3)
|
|
|
|
_, 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 are writeable by default", func(t *testing.T) {
|
|
settings := setting.FeatureMgmtSettings{
|
|
AllowEditing: true,
|
|
UpdateWebhook: "bogus",
|
|
}
|
|
result := runGetScenario(t, features, settings, readPermissions, http.StatusOK)
|
|
assert.Len(t, result, 3)
|
|
|
|
t4, ok := findResult(t, result, "toggle4")
|
|
assert.True(t, ok)
|
|
assert.True(t, t4.ReadOnly)
|
|
t5, ok := findResult(t, result, "toggle5")
|
|
assert.True(t, ok)
|
|
assert.False(t, t5.ReadOnly)
|
|
t6, ok := findResult(t, result, "toggle6")
|
|
assert.True(t, ok)
|
|
assert.False(t, t6.ReadOnly)
|
|
})
|
|
|
|
t.Run("all toggles are read-only when server is misconfigured", func(t *testing.T) {
|
|
settings := setting.FeatureMgmtSettings{
|
|
AllowEditing: false,
|
|
UpdateWebhook: "",
|
|
}
|
|
result := runGetScenario(t, features, settings, readPermissions, http.StatusOK)
|
|
assert.Len(t, result, 3)
|
|
|
|
t4, ok := findResult(t, result, "toggle4")
|
|
assert.True(t, ok)
|
|
assert.True(t, t4.ReadOnly)
|
|
t5, ok := findResult(t, result, "toggle5")
|
|
assert.True(t, ok)
|
|
assert.True(t, t5.ReadOnly)
|
|
t6, ok := findResult(t, result, "toggle6")
|
|
assert.True(t, ok)
|
|
assert.True(t, t6.ReadOnly)
|
|
})
|
|
})
|
|
}
|
|
|
|
func TestSetFeatureToggles(t *testing.T) {
|
|
writePermissions := []accesscontrol.Permission{{Action: accesscontrol.ActionFeatureManagementWrite}}
|
|
|
|
t.Run("fails without adequate permissions", func(t *testing.T) {
|
|
res := runSetScenario(t, nil, nil, setting.FeatureMgmtSettings{}, []accesscontrol.Permission{}, http.StatusForbidden)
|
|
defer func() { require.NoError(t, res.Body.Close()) }()
|
|
})
|
|
|
|
t.Run("fails when toggle editing is not enabled", func(t *testing.T) {
|
|
res := runSetScenario(t, nil, nil, setting.FeatureMgmtSettings{}, writePermissions, http.StatusForbidden)
|
|
defer func() { require.NoError(t, res.Body.Close()) }()
|
|
p := readBody(t, res.Body)
|
|
assert.Equal(t, "feature toggles are read-only", p["message"])
|
|
})
|
|
|
|
t.Run("fails when update toggle url is not set", func(t *testing.T) {
|
|
s := setting.FeatureMgmtSettings{
|
|
AllowEditing: true,
|
|
}
|
|
res := runSetScenario(t, nil, nil, s, writePermissions, http.StatusInternalServerError)
|
|
defer func() { require.NoError(t, res.Body.Close()) }()
|
|
p := readBody(t, res.Body)
|
|
assert.Equal(t, "feature toggles service is misconfigured", p["message"])
|
|
})
|
|
|
|
t.Run("fails with non-existent toggle", func(t *testing.T) {
|
|
features := []*featuremgmt.FeatureFlag{
|
|
{
|
|
Name: "toggle1",
|
|
Enabled: true,
|
|
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
|
}, {
|
|
Name: "toggle2",
|
|
Enabled: false,
|
|
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
|
},
|
|
}
|
|
|
|
updates := []featuremgmt.FeatureToggleDTO{
|
|
{
|
|
Name: "toggle3",
|
|
Enabled: true,
|
|
},
|
|
}
|
|
|
|
s := setting.FeatureMgmtSettings{
|
|
AllowEditing: true,
|
|
UpdateWebhook: "random",
|
|
}
|
|
res := runSetScenario(t, features, updates, s, writePermissions, http.StatusBadRequest)
|
|
defer func() { require.NoError(t, res.Body.Close()) }()
|
|
p := readBody(t, res.Body)
|
|
assert.Equal(t, "invalid toggle passed in", p["message"])
|
|
})
|
|
|
|
t.Run("fails with read-only toggles", func(t *testing.T) {
|
|
features := []*featuremgmt.FeatureFlag{
|
|
{
|
|
Name: featuremgmt.FlagFeatureToggleAdminPage,
|
|
Enabled: true,
|
|
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
|
}, {
|
|
Name: "toggle2",
|
|
Enabled: false,
|
|
Stage: featuremgmt.FeatureStagePublicPreview,
|
|
}, {
|
|
Name: "toggle3",
|
|
Enabled: false,
|
|
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
|
},
|
|
}
|
|
|
|
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) {
|
|
updates := []featuremgmt.FeatureToggleDTO{
|
|
{
|
|
Name: featuremgmt.FlagFeatureToggleAdminPage,
|
|
Enabled: true,
|
|
},
|
|
}
|
|
res := runSetScenario(t, features, updates, s, writePermissions, http.StatusBadRequest)
|
|
defer func() { require.NoError(t, res.Body.Close()) }()
|
|
p := readBody(t, res.Body)
|
|
assert.Equal(t, fmt.Sprintf("invalid toggle passed in: %s", featuremgmt.FlagFeatureToggleAdminPage), p["error"])
|
|
})
|
|
|
|
t.Run("because it is not GA or Deprecated", func(t *testing.T) {
|
|
updates := []featuremgmt.FeatureToggleDTO{
|
|
{
|
|
Name: "toggle2",
|
|
Enabled: true,
|
|
},
|
|
}
|
|
res := runSetScenario(t, features, updates, s, writePermissions, http.StatusBadRequest)
|
|
defer func() { require.NoError(t, res.Body.Close()) }()
|
|
p := readBody(t, res.Body)
|
|
assert.Equal(t, "invalid toggle passed in: toggle2", p["error"])
|
|
})
|
|
|
|
t.Run("because it is configured to be read-only", func(t *testing.T) {
|
|
updates := []featuremgmt.FeatureToggleDTO{
|
|
{
|
|
Name: "toggle3",
|
|
Enabled: true,
|
|
},
|
|
}
|
|
res := runSetScenario(t, features, updates, s, writePermissions, http.StatusBadRequest)
|
|
defer func() { require.NoError(t, res.Body.Close()) }()
|
|
p := readBody(t, res.Body)
|
|
assert.Equal(t, "invalid toggle passed in: toggle3", p["error"])
|
|
})
|
|
})
|
|
|
|
t.Run("when all conditions met", func(t *testing.T) {
|
|
features := []*featuremgmt.FeatureFlag{
|
|
{
|
|
Name: featuremgmt.FlagFeatureToggleAdminPage,
|
|
Enabled: true,
|
|
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
|
}, {
|
|
Name: "toggle2",
|
|
Enabled: false,
|
|
Stage: featuremgmt.FeatureStagePublicPreview,
|
|
}, {
|
|
Name: "toggle3",
|
|
Enabled: false,
|
|
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
|
}, {
|
|
Name: "toggle4",
|
|
Enabled: false,
|
|
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
|
}, {
|
|
Name: "toggle5",
|
|
Enabled: false,
|
|
Stage: featuremgmt.FeatureStageDeprecated,
|
|
},
|
|
}
|
|
|
|
s := setting.FeatureMgmtSettings{
|
|
AllowEditing: true,
|
|
UpdateWebhook: "random",
|
|
UpdateWebhookToken: "token",
|
|
ReadOnlyToggles: map[string]struct{}{
|
|
"toggle3": {},
|
|
},
|
|
}
|
|
|
|
updates := []featuremgmt.FeatureToggleDTO{
|
|
{
|
|
Name: "toggle4",
|
|
Enabled: true,
|
|
}, {
|
|
Name: "toggle5",
|
|
Enabled: 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
|
|
res := runSetScenario(t, features, updates, s, writePermissions, http.StatusBadRequest)
|
|
defer func() { require.NoError(t, res.Body.Close()) }()
|
|
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
|
|
})
|
|
|
|
t.Run("succeed when webhook request is successul", 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 UpdatePayload
|
|
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
|
|
res := runSetScenario(t, features, updates, s, writePermissions, http.StatusOK)
|
|
defer func() { require.NoError(t, res.Body.Close()) }()
|
|
assert.Equal(t, http.StatusOK, res.StatusCode)
|
|
})
|
|
})
|
|
}
|
|
|
|
func findResult(t *testing.T, result []featuremgmt.FeatureToggleDTO, name string) (featuremgmt.FeatureToggleDTO, bool) {
|
|
t.Helper()
|
|
|
|
for _, t := range result {
|
|
if t.Name == name {
|
|
return t, true
|
|
}
|
|
}
|
|
return featuremgmt.FeatureToggleDTO{}, false
|
|
}
|
|
|
|
func readBody(t *testing.T, rc io.ReadCloser) map[string]any {
|
|
t.Helper()
|
|
|
|
b, err := io.ReadAll(rc)
|
|
require.NoError(t, err)
|
|
payload := map[string]any{}
|
|
require.NoError(t, json.Unmarshal(b, &payload))
|
|
return payload
|
|
}
|
|
|
|
func runGetScenario(
|
|
t *testing.T,
|
|
features []*featuremgmt.FeatureFlag,
|
|
settings setting.FeatureMgmtSettings,
|
|
permissions []accesscontrol.Permission,
|
|
expectedCode int,
|
|
) []featuremgmt.FeatureToggleDTO {
|
|
// Set up server and send request
|
|
cfg := setting.NewCfg()
|
|
cfg.FeatureManagement = settings
|
|
|
|
server := SetupAPITestServer(t, func(hs *HTTPServer) {
|
|
hs.Cfg = cfg
|
|
hs.Features = featuremgmt.WithFeatureFlags(append([]*featuremgmt.FeatureFlag{{
|
|
Name: featuremgmt.FlagFeatureToggleAdminPage,
|
|
Enabled: true,
|
|
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
|
}}, features...))
|
|
hs.orgService = orgtest.NewOrgServiceFake()
|
|
hs.userService = &usertest.FakeUserService{
|
|
ExpectedUser: &user.User{ID: 1},
|
|
}
|
|
hs.log = log.New("test")
|
|
})
|
|
req := webtest.RequestWithSignedInUser(server.NewGetRequest("/api/featuremgmt"), userWithPermissions(1, permissions))
|
|
res, err := server.SendJSON(req)
|
|
defer func() { require.NoError(t, res.Body.Close()) }()
|
|
|
|
// Do some general checks for every request
|
|
require.NoError(t, err)
|
|
require.Equal(t, expectedCode, res.StatusCode)
|
|
if res.StatusCode >= 400 {
|
|
return nil
|
|
}
|
|
|
|
var result []featuremgmt.FeatureToggleDTO
|
|
err = json.NewDecoder(res.Body).Decode(&result)
|
|
require.NoError(t, err)
|
|
|
|
for i := 0; i < len(result); {
|
|
ft := result[i]
|
|
// Always make sure admin page toggle is read-only, then remove it to make assertions easier
|
|
if ft.Name == featuremgmt.FlagFeatureToggleAdminPage {
|
|
assert.True(t, ft.ReadOnly)
|
|
result = append(result[:i], result[i+1:]...)
|
|
continue
|
|
}
|
|
|
|
// Make sure toggles explicitly marked "hidden" by config are hidden
|
|
if _, ok := cfg.FeatureManagement.HiddenToggles[ft.Name]; ok {
|
|
t.Fail()
|
|
}
|
|
|
|
// Make sure toggles explicitly marked "read only" by config are read only
|
|
if _, ok := cfg.FeatureManagement.ReadOnlyToggles[ft.Name]; ok {
|
|
assert.True(t, ft.ReadOnly)
|
|
}
|
|
i++
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func runSetScenario(
|
|
t *testing.T,
|
|
serverFeatures []*featuremgmt.FeatureFlag,
|
|
updateFeatures []featuremgmt.FeatureToggleDTO,
|
|
settings setting.FeatureMgmtSettings,
|
|
permissions []accesscontrol.Permission,
|
|
expectedCode int,
|
|
) *http.Response {
|
|
// Set up server and send request
|
|
cfg := setting.NewCfg()
|
|
cfg.FeatureManagement = settings
|
|
|
|
server := SetupAPITestServer(t, func(hs *HTTPServer) {
|
|
hs.Cfg = cfg
|
|
hs.Features = featuremgmt.WithFeatureFlags(append([]*featuremgmt.FeatureFlag{{
|
|
Name: featuremgmt.FlagFeatureToggleAdminPage,
|
|
Enabled: true,
|
|
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
|
}}, serverFeatures...))
|
|
hs.orgService = orgtest.NewOrgServiceFake()
|
|
hs.userService = &usertest.FakeUserService{
|
|
ExpectedUser: &user.User{ID: 1},
|
|
}
|
|
hs.log = log.New("test")
|
|
})
|
|
|
|
cmd := featuremgmt.UpdateFeatureTogglesCommand{
|
|
FeatureToggles: updateFeatures,
|
|
}
|
|
b, err := json.Marshal(cmd)
|
|
require.NoError(t, err)
|
|
req := webtest.RequestWithSignedInUser(server.NewPostRequest("/api/featuremgmt", bytes.NewReader(b)), userWithPermissions(1, permissions))
|
|
res, err := server.SendJSON(req)
|
|
|
|
require.NoError(t, err)
|
|
require.NotNil(t, res)
|
|
require.Equal(t, expectedCode, res.StatusCode)
|
|
|
|
return res
|
|
}
|