diff --git a/integrationtests/all_test.go b/integrationtests/all_test.go index b823e456..559a5c87 100644 --- a/integrationtests/all_test.go +++ b/integrationtests/all_test.go @@ -23,6 +23,14 @@ func TestEndToEnd(t *testing.T) { testStandardMode(t, manager) }) + t.Run("standard mode with prerequisites", func(t *testing.T) { + // The standard tests above use simple flags that don't contain prerequisite relationships. This + // test explicitly configures flags with prerequisites, and verifies that client side flag evals contain + // prerequisite metadata for each flag. This information needs to be passed to client-side SDKs so that they + // can generate prerequisite events to power LaunchDarkly SaaS features. + testStandardModeWithPrerequisites(t, manager) + }) + t.Run("standard mode with payload filters", func(t *testing.T) { t.Run("default filters", func(t *testing.T) { // This case is similar to the "standard mode" test above, except with payload filtering in the picture. diff --git a/integrationtests/api_helpers_test.go b/integrationtests/api_helpers_test.go index 3498e5ea..7e160d4c 100644 --- a/integrationtests/api_helpers_test.go +++ b/integrationtests/api_helpers_test.go @@ -5,6 +5,7 @@ package integrationtests import ( "context" "encoding/json" + "errors" "fmt" "strings" "time" @@ -32,7 +33,8 @@ func (a *apiHelper) logResult(desc string, err error) error { return nil } addInfo := "" - if gse, ok := err.(ldapi.GenericOpenAPIError); ok { + var gse *ldapi.GenericOpenAPIError + if errors.As(err, &gse) { body := string(gse.Body()) addInfo = " - " + string(body) } diff --git a/integrationtests/flag_builder_test.go b/integrationtests/flag_builder_test.go new file mode 100644 index 00000000..560718f7 --- /dev/null +++ b/integrationtests/flag_builder_test.go @@ -0,0 +1,137 @@ +//go:build integrationtests + +package integrationtests + +import ( + "fmt" + + ldapi "github.com/launchdarkly/api-client-go/v13" + "github.com/launchdarkly/go-sdk-common/v3/ldvalue" +) + +type flagBuilder struct { + key string + projectKey string + envKey string + offVariation int + fallthroughVariation int + on bool + variations []ldapi.Variation + prerequisites []ldapi.Prerequisite + clientSide ldapi.ClientSideAvailabilityPost + helper *apiHelper +} + +// newFlagBuilder creates a builder for a flag which will be created in the specified project and environment. +// By default, the flag has two variations: off = false, and on = true. The flag is on by default. +// Additionally, the flag is available to both mobile and client SDKs. +func newFlagBuilder(helper *apiHelper, flagKey string, projectKey string, envKey string) *flagBuilder { + builder := &flagBuilder{ + key: flagKey, + projectKey: projectKey, + envKey: envKey, + on: true, + offVariation: 0, + fallthroughVariation: 1, + helper: helper, + clientSide: ldapi.ClientSideAvailabilityPost{ + UsingMobileKey: true, + UsingEnvironmentId: true, + }, + } + return builder.Variations(ldvalue.Bool(false), ldvalue.Bool(true)) +} + +// Variations overwrites the flag's variations. A valid flag has two or more variations. +func (f *flagBuilder) Variations(variation1 ldvalue.Value, variations ...ldvalue.Value) *flagBuilder { + f.variations = nil + for _, value := range append([]ldvalue.Value{variation1}, variations...) { + valueAsInterface := value.AsArbitraryValue() + f.variations = append(f.variations, ldapi.Variation{Value: &valueAsInterface}) + } + return f +} + +// ClientSideUsingEnvironmentID enables the flag for clients that use environment ID for auth. +func (f *flagBuilder) ClientSideUsingEnvironmentID(usingEnvID bool) *flagBuilder { + f.clientSide.UsingEnvironmentId = usingEnvID + return f +} + +// ClientSideUsingMobileKey enables the flag for clients that use mobile keys for auth. +func (f *flagBuilder) ClientSideUsingMobileKey(usingMobileKey bool) *flagBuilder { + f.clientSide.UsingMobileKey = usingMobileKey + return f +} + +// Prerequisites overwrites the flag's prerequisites. +func (f *flagBuilder) Prerequisites(prerequisites []ldapi.Prerequisite) *flagBuilder { + f.prerequisites = prerequisites + return f +} + +// Prerequisite is a helper that calls Prerequisites with a single value. +func (f *flagBuilder) Prerequisite(prerequisiteKey string, variation int32) *flagBuilder { + return f.Prerequisites([]ldapi.Prerequisite{{Key: prerequisiteKey, Variation: variation}}) +} + +// OffVariation sets the flag's off variation. +func (f *flagBuilder) OffVariation(v int) *flagBuilder { + f.offVariation = v + return f +} + +// FallthroughVariation sets the flag's fallthrough variation. +func (f *flagBuilder) FallthroughVariation(v int) *flagBuilder { + f.fallthroughVariation = v + return f +} + +// On enables or disables flag targeting. +func (f *flagBuilder) On(on bool) *flagBuilder { + f.on = on + return f +} + +// Create creates the flag using the LD REST API. +func (f *flagBuilder) Create() error { + flagPost := ldapi.FeatureFlagBody{ + Name: f.key, + Key: f.key, + ClientSideAvailability: &f.clientSide, + } + + _, _, err := f.helper.apiClient.FeatureFlagsApi. + PostFeatureFlag(f.helper.apiContext, f.projectKey). + FeatureFlagBody(flagPost). + Execute() + + if err := f.logAPIResult("create flag", err); err != nil { + return err + } + + envPrefix := fmt.Sprintf("/environments/%s", f.envKey) + patch := ldapi.PatchWithComment{ + Patch: []ldapi.PatchOperation{ + makePatch("replace", envPrefix+"/offVariation", f.offVariation), + makePatch("replace", envPrefix+"/fallthrough/variation", f.fallthroughVariation), + makePatch("replace", envPrefix+"/on", f.on), + makePatch("replace", envPrefix+"/prerequisites", f.prerequisites), + }, + } + + _, _, err = f.helper.apiClient.FeatureFlagsApi. + PatchFeatureFlag(f.helper.apiContext, f.projectKey, f.key). + PatchWithComment(patch). + Execute() + + return f.logAPIResult("patch flag", err) +} + +func (f *flagBuilder) logAPIResult(desc string, err error) error { + return f.helper.logResult(f.scopedOp(desc), err) +} + +func (f *flagBuilder) scopedOp(desc string) string { + return fmt.Sprintf("%s %s in %s/%s", desc, f.key, f.projectKey, f.envKey) +} diff --git a/integrationtests/standard_mode_prerequisite_flags_test.go b/integrationtests/standard_mode_prerequisite_flags_test.go new file mode 100644 index 00000000..4a6f23b9 --- /dev/null +++ b/integrationtests/standard_mode_prerequisite_flags_test.go @@ -0,0 +1,343 @@ +//go:build integrationtests + +package integrationtests + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "golang.org/x/exp/maps" + + ldapi "github.com/launchdarkly/api-client-go/v13" + "github.com/launchdarkly/go-sdk-common/v3/ldvalue" + "github.com/stretchr/testify/require" +) + +// scopedApiHelper is meant to be a wrapper around the base apiHelper which scopes operations to a single +// project/environment. It was created specifically for the prerequisite tests, since we aren't trying to verify +// assertions across projects/environments - just that prerequisites are correct within a single payload. +// This pattern could be extended or refactored into a dedicated helper package if necessary. +type scopedApiHelper struct { + project projectInfo + env environmentInfo + apiHelper *apiHelper +} + +// newScopedApiHelper wraps an existing apiHelper, automatically creating a project with a single environment. +// Be sure to call cleanup() when done to delete the project, otherwise orphan projects will accumulate in the +// testing account. +func newScopedApiHelper(apiHelper *apiHelper) (*scopedApiHelper, error) { + project, envs, err := apiHelper.createProject(1) + if err != nil { + return nil, err + } + return &scopedApiHelper{ + apiHelper: apiHelper, + project: project, + env: envs[0], + }, nil +} + +// envVariables returns all the environment variables needed for Relay to be aware of the environment +// and authenticate with it. +func (s *scopedApiHelper) envVariables() map[string]string { + return map[string]string{ + "LD_ENV_" + string(s.env.name): string(s.env.sdkKey), + "LD_MOBILE_KEY_" + string(s.env.name): string(s.env.mobileKey), + "LD_CLIENT_SIDE_ID_" + string(s.env.name): string(s.env.id), + } +} + +// projsAndEnvs returns a map of project -> environment, which is necessary to interact with the integration +// test manager's awaitEnvironments method. +func (s *scopedApiHelper) projAndEnvs() projsAndEnvs { + return projsAndEnvs{ + s.project: {s.env}, + } +} + +// cleanup deletes the project and environment created by this scopedApiHelper. A common pattern in tests would be +// calling newScopedApiHelper, then deferring the cleanup call immediately after. +func (s *scopedApiHelper) cleanup() { + s.apiHelper.deleteProject(s.project) +} + +// newFlag creates a new flag in the project. In LaunchDarkly, flags are created across all environments. The flag +// builder allows configuring different aspects of the flag, such as variations and prerequisites - this configuration +// is scoped to the single environment created by the scopedApiHelper. +func (s *scopedApiHelper) newFlag(key string) *flagBuilder { + return newFlagBuilder(s.apiHelper, key, s.project.key, s.env.key) +} + +func testStandardModeWithPrerequisites(t *testing.T, manager *integrationTestManager) { + t.Run("includes top-level prerequisites", func(t *testing.T) { + api, err := newScopedApiHelper(manager.apiHelper) + require.NoError(t, err) + defer api.cleanup() + + flagSetup := func() error { + if err := api.newFlag("indirectPrereqOf1"). + On(true). + Variations(ldvalue.Bool(false), ldvalue.Bool(true)). + Create(); err != nil { + return err + } + + if err := api.newFlag("directPrereq1"). + On(true). + Variations(ldvalue.Bool(false), ldvalue.Bool(true)). + Prerequisite("indirectPrereqOf1", 1). + Create(); err != nil { + return err + } + + if err := api.newFlag("directPrereq2"). + On(true). + Variations(ldvalue.Bool(false), ldvalue.Bool(true)). + Create(); err != nil { + return err + } + + return api.newFlag("topLevel"). + On(true). + Variations(ldvalue.Bool(false), ldvalue.Bool(true)). + Prerequisites([]ldapi.Prerequisite{ + {Key: "directPrereq1", Variation: 1}, + {Key: "directPrereq2", Variation: 1}, + }).Create() + } + + require.NoError(t, flagSetup()) + + manager.startRelay(t, api.envVariables()) + defer manager.stopRelay(t) + + manager.awaitEnvironments(t, api.projAndEnvs(), nil, func(proj projectInfo, env environmentInfo) string { + return env.name + }) + + userJSON := `{"key":"any-user-key"}` + + url := manager.sdkEvalxUsersRoute(t, api.env.id, userJSON) + gotPrerequisites := manager.getFlagPrerequisites(t, api.env.key, url, api.env.id) + + expectedPrerequisites := map[string][]string{ + "topLevel": {"directPrereq1", "directPrereq2"}, + "directPrereq1": {"indirectPrereqOf1"}, + "directPrereq2": {}, + "indirectPrereqOf1": {}, + } + + requirePrerequisitesEqual(t, expectedPrerequisites, gotPrerequisites) + }) + + t.Run("ignores prereqs if not evaluated", func(t *testing.T) { + api, err := newScopedApiHelper(manager.apiHelper) + require.NoError(t, err) + defer api.cleanup() + + flagSetup := func() error { + if err := api.newFlag("prereq1"). + On(true). + Variations(ldvalue.Bool(false), ldvalue.Bool(true)). + Create(); err != nil { + return err + } + + if err := api.newFlag("prereq2"). + On(true). + Variations(ldvalue.Bool(false), ldvalue.Bool(true)). + Create(); err != nil { + return err + } + + if err := api.newFlag("flagOn"). + On(true). + Variations(ldvalue.Bool(false), ldvalue.Bool(true)). + Prerequisite("prereq1", 1). + Create(); err != nil { + return err + } + + if err := api.newFlag("flagOff"). + On(false). + Variations(ldvalue.Bool(false), ldvalue.Bool(true)). + Prerequisite("prereq1", 1). + Create(); err != nil { + return err + } + + return api.newFlag("failedPrereq"). + On(true). + Variations(ldvalue.Bool(false), ldvalue.Bool(true)). + Prerequisites([]ldapi.Prerequisite{ + {Key: "prereq1", Variation: 0}, // wrong variation! + {Key: "prereq2", Variation: 1}, // correct variation, but we shouldn't see it since the first prereq failed + }).Create() + } + + require.NoError(t, flagSetup()) + + manager.startRelay(t, api.envVariables()) + defer manager.stopRelay(t) + + manager.awaitEnvironments(t, api.projAndEnvs(), nil, func(proj projectInfo, env environmentInfo) string { + return env.name + }) + + userJSON := `{"key":"any-user-key"}` + + url := manager.sdkEvalxUsersRoute(t, api.env.id, userJSON) + gotPrerequisites := manager.getFlagPrerequisites(t, api.env.key, url, api.env.id) + + expectedPrerequisites := map[string][]string{ + "flagOn": {"prereq1"}, + "flagOff": {}, + "failedPrereq": {"prereq1"}, + "prereq1": {}, + "prereq2": {}, + } + requirePrerequisitesEqual(t, expectedPrerequisites, gotPrerequisites) + }) + + t.Run("exposes prerequisite relationship even if prereq is hidden from clients", func(t *testing.T) { + t.Run("partially visible to environment ID", func(t *testing.T) { + api, err := newScopedApiHelper(manager.apiHelper) + require.NoError(t, err) + defer api.cleanup() + + flagSetup := func() error { + if err := api.newFlag("prereq1"). + On(true).Variations(ldvalue.Bool(false), ldvalue.Bool(true)). + ClientSideUsingEnvironmentID(true). + ClientSideUsingMobileKey(false). + Create(); err != nil { + return err + } + + if err := api.newFlag("prereq2"). + On(true).Variations(ldvalue.Bool(false), ldvalue.Bool(true)). + ClientSideUsingEnvironmentID(false). + ClientSideUsingMobileKey(false). + Create(); err != nil { + return err + } + + return api.newFlag("flag"). + On(true).Variations(ldvalue.Bool(false), ldvalue.Bool(true)). + ClientSideUsingEnvironmentID(true). + ClientSideUsingMobileKey(false). + Prerequisites([]ldapi.Prerequisite{ + {Key: "prereq1", Variation: 1}, + {Key: "prereq2", Variation: 1}, + }).Create() + } + + require.NoError(t, flagSetup()) + + manager.startRelay(t, api.envVariables()) + defer manager.stopRelay(t) + + manager.awaitEnvironments(t, api.projAndEnvs(), nil, func(proj projectInfo, env environmentInfo) string { + return env.name + }) + + userJSON := `{"key":"any-user-key"}` + + url := manager.sdkEvalxUsersRoute(t, api.env.id, userJSON) + gotPrerequisites := manager.getFlagPrerequisites(t, api.env.key, url, api.env.id) + + // prereq1 is visible to env ID, but prereq2 is not. The top level flag + // is visible to env ID. We should see an eval result for the top level flag (with prereqs), + // and for prereq1, but not for prereq2. + expectedPrerequisites := map[string][]string{ + "flag": {"prereq1", "prereq2"}, + "prereq1": {}, + } + + requirePrerequisitesEqual(t, expectedPrerequisites, gotPrerequisites) + }) + + t.Run("partially visible to mobile key", func(t *testing.T) { + api, err := newScopedApiHelper(manager.apiHelper) + require.NoError(t, err) + defer api.cleanup() + + flagSetup := func() error { + if err := api.newFlag("prereq1"). + On(true).Variations(ldvalue.Bool(false), ldvalue.Bool(true)). + ClientSideUsingMobileKey(true). + ClientSideUsingEnvironmentID(false). + Create(); err != nil { + return err + } + + if err := api.newFlag("prereq2"). + On(true).Variations(ldvalue.Bool(false), ldvalue.Bool(true)). + ClientSideUsingMobileKey(false). + ClientSideUsingEnvironmentID(false). + Create(); err != nil { + return err + } + + return api.newFlag("flag"). + On(true).Variations(ldvalue.Bool(false), ldvalue.Bool(true)). + ClientSideUsingMobileKey(true). + ClientSideUsingEnvironmentID(false). + Prerequisites([]ldapi.Prerequisite{ + {Key: "prereq1", Variation: 1}, + {Key: "prereq2", Variation: 1}, + }).Create() + } + + require.NoError(t, flagSetup()) + + manager.startRelay(t, api.envVariables()) + defer manager.stopRelay(t) + + manager.awaitEnvironments(t, api.projAndEnvs(), nil, func(proj projectInfo, env environmentInfo) string { + return env.name + }) + + userJSON := `{"key":"any-user-key"}` + + // Note: 'msdk' not 'sdk' like the environment ID test. + url := manager.msdkEvalxUsersRoute(t, userJSON) + // Note: passing in mobile key here, not environment ID. + gotPrerequisites := manager.getFlagPrerequisites(t, api.env.key, url, api.env.mobileKey) + + // prereq1 is visible to mobile keys, but prereq2 is not. The top level flag + // is visible to mobile keys. We should see an eval result for the top level flag (with prereqs), + // and for prereq1, but not for prereq2. + expectedPrerequisites := map[string][]string{ + "flag": {"prereq1", "prereq2"}, + "prereq1": {}, + } + + requirePrerequisitesEqual(t, expectedPrerequisites, gotPrerequisites) + }) + }) +} + +func requirePrerequisitesEqual(t *testing.T, expected map[string][]string, got ldvalue.Value) { + expectedKeys := maps.Keys(expected) + gotKeys := got.Keys(nil) + + require.ElementsMatch(t, expectedKeys, gotKeys) + + for flagKey, prereqKeys := range expected { + prereqArray := got.GetByKey(flagKey).AsValueArray() + + actualCount := 0 + if prereqArray.IsDefined() { + actualCount = prereqArray.Count() + } + + assert.Equal(t, len(prereqKeys), actualCount) + + for i, expectedPrereqKey := range prereqKeys { + actualPrereqKey := prereqArray.Get(i).StringValue() + assert.Equal(t, expectedPrereqKey, actualPrereqKey, "prerequisites of flag %s @ index %d", flagKey, i) + } + } +} diff --git a/integrationtests/test_manager_test.go b/integrationtests/test_manager_test.go index 7b457e58..51680430 100644 --- a/integrationtests/test_manager_test.go +++ b/integrationtests/test_manager_test.go @@ -17,6 +17,8 @@ import ( "testing" "time" + "github.com/launchdarkly/ld-relay/v8/internal/credential" + "github.com/launchdarkly/ld-relay/v8/config" "github.com/launchdarkly/ld-relay/v8/integrationtests/docker" "github.com/launchdarkly/ld-relay/v8/integrationtests/oshelpers" @@ -452,6 +454,60 @@ func (m *integrationTestManager) getFlagValues(t *testing.T, proj projectInfo, e return ldvalue.Null() } +// getFlagPrerequisites fetches a payload from the given URL, which is expected to be a Relay polling evaluation +// endpoint, and returns the "prerequisites" field of the flags in the payload. +func (m *integrationTestManager) getFlagPrerequisites(t *testing.T, envKey string, + url *url.URL, auth credential.SDKCredential) ldvalue.Value { + req, err := http.NewRequest("GET", url.String(), nil) + require.NoError(t, err) + req.Header.Add("Authorization", auth.GetAuthorizationHeaderValue()) + resp, err := m.makeHTTPRequestToRelay(req) + require.NoError(t, err) + if assert.Equal(t, 200, resp.StatusCode, "requested flags for environment %s with credential %s", envKey, auth.Masked()) { + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + require.NoError(t, err) + flagData := ldvalue.Parse(data) + if !flagData.Equal(ldvalue.Null()) { + valuesObject := ldvalue.ObjectBuild() + for _, key := range flagData.Keys(nil) { + valuesObject.Set(key, flagData.GetByKey(key).GetByKey("prerequisites")) + } + return valuesObject.Build() + } + m.loggers.Errorf("Flags poll request returned invalid response for environment %s with credential %s: %s", + envKey, auth.Masked(), string(data)) + t.FailNow() + } else { + m.loggers.Errorf("Flags poll request for environment %s with credential %s failed with status %d", + envKey, auth.Masked(), resp.StatusCode) + t.FailNow() + } + return ldvalue.Null() +} + +func (m *integrationTestManager) msdkEvalxUsersRoute(t *testing.T, userJSON string) *url.URL { + userBase64 := base64.URLEncoding.EncodeToString([]byte(userJSON)) + + u, err := url.Parse(m.relayBaseURL + "/msdk/evalx/users/" + userBase64) + if err != nil { + t.Fatalf("couldn't parse flag evaluation URL: %v", err) + } + + return u +} + +func (m *integrationTestManager) sdkEvalxUsersRoute(t *testing.T, envID config.EnvironmentID, userJSON string) *url.URL { + userBase64 := base64.URLEncoding.EncodeToString([]byte(userJSON)) + + u, err := url.Parse(m.relayBaseURL + "/sdk/evalx/" + envID.String() + "/users/" + userBase64) + if err != nil { + t.Fatalf("couldn't parse flag evaluation URL: %v", err) + } + + return u +} + func (m *integrationTestManager) withExtraContainer( t *testing.T, imageName string, diff --git a/relay/relay_endpoints.go b/relay/relay_endpoints.go index 3eb8a8bc..b769b738 100644 --- a/relay/relay_endpoints.go +++ b/relay/relay_endpoints.go @@ -11,6 +11,8 @@ import ( "strconv" "time" + ldeval "github.com/launchdarkly/go-server-sdk-evaluation/v3" + "github.com/launchdarkly/ld-relay/v8/internal/basictypes" "github.com/launchdarkly/ld-relay/v8/internal/logging" "github.com/launchdarkly/ld-relay/v8/internal/middleware" @@ -247,7 +249,13 @@ func evaluateAllShared(w http.ResponseWriter, req *http.Request, sdkKind basicty } } - result := evaluator.Evaluate(flag, ldContext, nil) + var prerequisites []string + result := evaluator.Evaluate(flag, ldContext, func(event ldeval.PrerequisiteFlagEvent) { + if event.TargetFlagKey == flag.Key { + prerequisites = append(prerequisites, event.PrerequisiteFlag.Key) + } + }) + detail := result.Detail isExperiment := result.IsExperiment @@ -262,6 +270,15 @@ func evaluateAllShared(w http.ResponseWriter, req *http.Request, sdkKind basicty } valueObj.Maybe("debugEventsUntilDate", flag.DebugEventsUntilDate != 0). Float64(float64(flag.DebugEventsUntilDate)) + + if len(prerequisites) > 0 { + prereqArray := valueObj.Name("prerequisites").Array() + for _, p := range prerequisites { + prereqArray.String(p) + } + prereqArray.End() + } + valueObj.End() } }