diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d7c5dbc --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +internal/cmd/testdata/script/*.txtar text eol=lf diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 17f6954..49d1269 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,9 @@ repos: rev: v6.0.0 hooks: - id: trailing-whitespace + exclude: ^internal/cmd/testdata/script/ - id: end-of-file-fixer + exclude: ^internal/cmd/testdata/script/ - id: check-yaml - id: check-added-large-files - id: check-merge-conflict diff --git a/go.mod b/go.mod index a7615ad..f9ce526 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/fatih/color v1.19.0 github.com/itchyny/gojq v0.12.19 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c + github.com/rogpeppe/go-internal v1.15.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.9 github.com/zalando/go-keyring v0.2.8 @@ -53,4 +54,5 @@ require ( golang.org/x/net v0.43.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.28.0 // indirect + golang.org/x/tools v0.35.0 // indirect ) diff --git a/go.sum b/go.sum index 521a9a2..dc7b615 100644 --- a/go.sum +++ b/go.sum @@ -97,6 +97,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= +github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= @@ -130,6 +132,8 @@ golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= +golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go index c0d3477..183dbc4 100644 --- a/internal/cmd/cmd_test.go +++ b/internal/cmd/cmd_test.go @@ -7,13 +7,10 @@ import ( "errors" "io" "net/http" - "net/http/httptest" "net/url" "os" "path/filepath" "regexp" - "sort" - "strconv" "strings" "sync" "testing" @@ -23,10 +20,7 @@ import ( "github.com/spf13/pflag" "github.com/zalando/go-keyring" - "github.com/Flagsmith/flagsmith-cli/v2/internal/api" "github.com/Flagsmith/flagsmith-cli/v2/internal/auth" - "github.com/Flagsmith/flagsmith-cli/v2/internal/cache" - "github.com/Flagsmith/flagsmith-cli/v2/internal/config" "github.com/Flagsmith/flagsmith-cli/v2/internal/version" ) @@ -135,1666 +129,29 @@ func (d *delayedReader) Read(p []byte) (int, error) { return d.r.Read(p) } -// fakeInstance is a Flagsmith instance stub covering the endpoints the auth -// slice touches. Organisations answers to the master key, the env bearer -// token, and the OAuth access token; users/me only to bearer credentials. -type fakeInstance struct { - srv *httptest.Server - - mu sync.Mutex - revoked []url.Values - orgs []map[string]any - projects map[string][]map[string]any // orgID -> projects - envs map[string][]map[string]any // projectID -> environments - created []string - createdEnvs []string - features map[string][]map[string]any // projectID -> features list; nil → default - lastFeatEnv string // last ?environment= seen by /features/ - lastFeatSeg string // last ?segment= seen by /features/ - lastFeatArch string // last ?is_archived= seen by /features/ - lastFeatSearch string // last ?search= seen by /features/ - featListCalls int // count of GET /features/ list calls - fsListCalls int // count of GET /features/feature-segments/ calls - fsDelay time.Duration // artificial latency for feature-segments - fsInFlight int // feature-segments requests currently being served - fsPeak int // high-water mark of fsInFlight - segListCalls int // count of GET /segments/ list calls - stListCalls int // count of GET /features/featurestates/ calls - idLookupCalls int // count of GET /identities/ (identifier lookups) - edgeLookups int // count of GET /edge-identities/ (uuid lookups) - envGetCalls int // count of GET /environments/{key}/ (retrieve) - envListCalls int // count of GET /environments/ list calls - projGetCalls int // count of GET /projects/{id}/ (retrieve) - orgListCalls int // count of GET /organisations/ list calls - tokenPosts int // count of POST /o/token/ (refresh) calls - updateCalls int // count of update-flag-v2 calls - lastUpdate map[string]any // last update-flag-v2 request body - lastDelete map[string]any // last delete-segment-override request body - workflowGated bool // when true, update endpoints return 403 - segmentMissing bool // when true, delete-segment-override returns 404 - - useEdge bool // GET /projects/{id}/ use_edge_identities - coreIdentities map[string]int // identifier -> identity id - coreOverrides map[int]map[int]*fakeFS // identity id -> feature id -> state - edgeOverrides map[string]map[int]*fakeFS // identifier -> feature id -> state - nextFSID int - lastIdentityWrite map[string]any // last core identity FS create/update body - lastEdgeWrite map[string]any // last edge identifier PUT body - lastEdgeDelete map[string]any // last edge identifier DELETE body - - segments map[int]map[string]any // segment id -> segment - nextSegmentID int - lastSegmentBody map[string]any // last segment create/update body - - featureSegments map[string][]map[string]any // feature id -> feature-segment rows (priority order) - featureStates map[string][]map[string]any // feature id -> admin featurestates rows - - // The SDK API surface `flagsmith evaluate` reads. sdkEnvFlags is keyed by - // environment key — an unrecognised key gets a 401, as the real SDK API - // does; sdkIdentityFlags overrides it per identifier ("" is the anonymous - // identity), so an identity evaluation can be told apart from the - // environment defaults. - sdkEnvFlags map[string][]map[string]any - sdkIdentityFlags map[string][]map[string]any - lastIdentify map[string]any // last POST /api/v1/identities/ body - sdkUserAgents []string // User-Agent of every SDK API request - sdkKeys []string // X-Environment-Key of every SDK API request - sdkStatus int // when non-zero, the SDK endpoints answer with it - sdkDelay time.Duration // artificial latency for the SDK endpoints - - nextFeatureID int - lastFeatureBody map[string]any // last feature create/update body - nextMVID int - lastMVBody map[string]any // last mv-options create/update body - lastOrgBody map[string]any // last organisation create/update body - lastProjectBody map[string]any // last project create/update body - nextOrgID int - lastEnvBody map[string]any // last environment create/update/clone body - serverKeys map[string][]map[string]any // env api_key -> server-side keys - nextServerKeyID int - lastServerKey map[string]any // last api-keys create body -} - -// envByAPIKey finds a stored environment by its client-side key, returning its -// project key too (caller holds the lock). -func (f *fakeInstance) envByAPIKey(key string) (string, map[string]any) { - for proj, list := range f.envs { - for _, e := range list { - if e["api_key"] == key { - return proj, e - } - } - } - return "", nil -} - -func (f *fakeInstance) orgByID(id int) map[string]any { - for _, o := range f.orgs { - if o["id"] == id { - return o - } - } - return nil -} - -// projectByID finds a stored project across all orgs (caller holds the lock). -func (f *fakeInstance) projectByID(id int) map[string]any { - for _, list := range f.projects { - for _, p := range list { - if p["id"] == id { - return p - } - } - } - return nil -} - -// featureByID finds a stored feature item by id (caller holds the lock). -func (f *fakeInstance) featureByID(project string, id int) map[string]any { - for _, it := range f.features[project] { - if it["id"] == id { - return it - } - } - return nil -} - -// fakeFS is a stored identity feature-state in the fake backend. -type fakeFS struct { - id int - enabled bool - value any +// shapeArgs prepends a shape prefix to a command line. +func shapeArgs(prefix []string, args ...string) []string { + return append(append([]string{}, prefix...), args...) } -func newFakeInstance(t *testing.T) *fakeInstance { - t.Helper() - f := &fakeInstance{ - orgs: []map[string]any{{"id": 3, "name": "Acme"}}, - projects: map[string][]map[string]any{ - "3": {{"id": 101, "name": "acme-api", "organisation": 3}}, - }, - envs: map[string][]map[string]any{ - "101": { - {"id": 1, "name": "Development", "api_key": "WqXhZk8sVY3dGgTqZ9pJmN"}, - {"id": 2, "name": "Production", "api_key": "K2mVsGdXhZ8kQqZ9pJmNbJ"}, - }, - "12345": { - {"id": 3, "name": "Development", "api_key": "WqXhZk8sVY3dGgTqZ9pJmN"}, - }, - }, - features: map[string][]map[string]any{"101": defaultFeatures()}, - coreIdentities: map[string]int{"user-1": 501}, - coreOverrides: map[int]map[int]*fakeFS{}, - edgeOverrides: map[string]map[int]*fakeFS{}, - nextFSID: 9000, - segments: map[int]map[string]any{ - 42: { - "id": 42, "name": "us-adults", "description": "Users in the US aged 18+", "feature": nil, - "rules": []any{map[string]any{"type": "ALL", "rules": []any{ - map[string]any{"type": "ANY", "conditions": []any{ - map[string]any{"property": "country", "operator": "IN", "value": `["US","CA"]`}, - map[string]any{"property": "age", "operator": "GREATER_THAN_INCLUSIVE", "value": "18"}, - }}, - }}}, - }, - 57: { - "id": 57, "name": "beta-optin", "description": "Opted into the beta", "feature": nil, - "rules": []any{map[string]any{"type": "ALL", "conditions": []any{ - map[string]any{"property": "beta", "operator": "IS_SET", "value": nil}, - }}}, - }, - 58: { - "id": 58, "name": "beta-cohort", "description": "Beta cohort for checkout-v2", "feature": 2, - "rules": []any{map[string]any{"type": "ALL", "conditions": []any{ - map[string]any{"property": "beta", "operator": "IS_SET", "value": nil}, - }}}, - }, - }, - sdkEnvFlags: map[string][]map[string]any{ - "WqXhZk8sVY3dGgTqZ9pJmN": sdkFlagsFrom(defaultFeatures()), - }, - sdkIdentityFlags: map[string][]map[string]any{}, - nextSegmentID: 100, - nextFeatureID: 900, - nextMVID: 300, - nextOrgID: 20, - serverKeys: map[string][]map[string]any{}, - nextServerKeyID: 500, - } - mux := http.NewServeMux() - mux.HandleFunc("GET /.well-known/oauth-authorization-server", func(w http.ResponseWriter, r *http.Request) { - json.NewEncoder(w).Encode(map[string]string{ - "issuer": f.srv.URL, - "authorization_endpoint": f.srv.URL + "/oauth/authorize/", - "token_endpoint": f.srv.URL + "/o/token/", - "revocation_endpoint": f.srv.URL + "/o/revoke_token/", - }) - }) - mux.HandleFunc("POST /o/token/", func(w http.ResponseWriter, r *http.Request) { - f.mu.Lock() - f.tokenPosts++ - f.mu.Unlock() - json.NewEncoder(w).Encode(map[string]any{ - "access_token": oauthAccess, - "refresh_token": "cmd-refresh", - "expires_in": 900, - "scope": auth.Scope, - "token_type": "Bearer", - }) - }) - mux.HandleFunc("POST /o/revoke_token/", func(w http.ResponseWriter, r *http.Request) { - r.ParseForm() - f.mu.Lock() - f.revoked = append(f.revoked, r.PostForm) - f.mu.Unlock() - }) - mux.HandleFunc("GET /api/v1/auth/users/me/", func(w http.ResponseWriter, r *http.Request) { - a := r.Header.Get("Authorization") - if a != "Bearer "+oauthAccess && a != "Bearer "+bearerToken { - w.WriteHeader(http.StatusUnauthorized) - return - } - json.NewEncoder(w).Encode(map[string]string{"email": "kim@example.com", "uuid": "u-1"}) - }) - authorized := func(r *http.Request) bool { - a := r.Header.Get("Authorization") - return a == "Api-Key "+masterKey || a == "Bearer "+oauthAccess || a == "Bearer "+bearerToken - } - mux.HandleFunc("GET /api/v1/organisations/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - f.mu.Lock() - f.orgListCalls++ - orgs := f.orgs - f.mu.Unlock() - json.NewEncoder(w).Encode(map[string]any{"count": len(orgs), "results": orgs}) - }) - mux.HandleFunc("GET /api/v1/projects/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - f.mu.Lock() - var projects []map[string]any - if org := r.URL.Query().Get("organisation"); org != "" { - projects = f.projects[org] - } else { - for _, list := range f.projects { - projects = append(projects, list...) - } - } - f.mu.Unlock() - json.NewEncoder(w).Encode(map[string]any{"count": len(projects), "results": projects}) - }) - mux.HandleFunc("POST /api/v1/projects/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - var body map[string]any - json.NewDecoder(r.Body).Decode(&body) - name, _ := body["name"].(string) - f.mu.Lock() - f.lastProjectBody = body - f.created = append(f.created, name) - proj := map[string]any{"id": 999, "name": name} - if org, ok := body["organisation"].(float64); ok { - proj["organisation"] = int(org) - f.projects[strconv.Itoa(int(org))] = append(f.projects[strconv.Itoa(int(org))], proj) - } - if f.envs["999"] == nil { - f.envs["999"] = []map[string]any{} // created projects start empty - } - f.mu.Unlock() - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(proj) - }) - mux.HandleFunc("GET /api/v1/environments/", func(w http.ResponseWriter, r *http.Request) { - f.mu.Lock() - f.envListCalls++ - f.mu.Unlock() - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - f.mu.Lock() - envs, known := f.envs[r.URL.Query().Get("project")] - f.mu.Unlock() - if !known { - w.WriteHeader(http.StatusForbidden) // no access to this project - return - } - json.NewEncoder(w).Encode(map[string]any{"count": len(envs), "results": envs}) - }) - mux.HandleFunc("POST /api/v1/environments/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - var body map[string]any - json.NewDecoder(r.Body).Decode(&body) - name, _ := body["name"].(string) - f.mu.Lock() - f.lastEnvBody = body - f.createdEnvs = append(f.createdEnvs, name) - env := map[string]any{"id": 42, "name": name, "api_key": "createdEnvKey00000000"} - for k, v := range body { - env[k] = v - } - env["api_key"] = "createdEnvKey00000000" - if proj, ok := body["project"].(float64); ok { - key := strconv.Itoa(int(proj)) - f.envs[key] = append(f.envs[key], env) - } - f.mu.Unlock() - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(env) - }) - mux.HandleFunc("GET /api/v1/environments/{api_key}/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - f.mu.Lock() - f.envGetCalls++ - _, env := f.envByAPIKey(r.PathValue("api_key")) - f.mu.Unlock() - if env == nil { - w.WriteHeader(http.StatusNotFound) - return - } - json.NewEncoder(w).Encode(env) - }) - mux.HandleFunc("PATCH /api/v1/environments/{api_key}/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - var body map[string]any - json.NewDecoder(r.Body).Decode(&body) - f.mu.Lock() - f.lastEnvBody = body - _, env := f.envByAPIKey(r.PathValue("api_key")) - if env != nil { - for k, v := range body { - env[k] = v - } - } - f.mu.Unlock() - json.NewEncoder(w).Encode(env) - }) - mux.HandleFunc("DELETE /api/v1/environments/{api_key}/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - key := r.PathValue("api_key") - f.mu.Lock() - for proj, list := range f.envs { - kept := list[:0:0] - for _, e := range list { - if e["api_key"] != key { - kept = append(kept, e) - } - } - f.envs[proj] = kept - } - f.mu.Unlock() - w.WriteHeader(http.StatusNoContent) - }) - mux.HandleFunc("GET /api/v1/environments/{api_key}/document/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - json.NewEncoder(w).Encode(map[string]any{ - "api_key": r.PathValue("api_key"), - "feature_states": []any{ - map[string]any{"feature": map[string]any{"name": "onboarding"}}, - map[string]any{"feature": map[string]any{"name": "checkout"}}, - }, - }) - }) - // Server-side SDK keys sub-resource. - mux.HandleFunc("GET /api/v1/environments/{api_key}/api-keys/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - f.mu.Lock() - keys := f.serverKeys[r.PathValue("api_key")] - f.mu.Unlock() - json.NewEncoder(w).Encode(keys) // bare array (pagination_class = None) - }) - mux.HandleFunc("POST /api/v1/environments/{api_key}/api-keys/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - env := r.PathValue("api_key") - var body map[string]any - json.NewDecoder(r.Body).Decode(&body) - f.mu.Lock() - f.lastServerKey = body - f.nextServerKeyID++ - key := map[string]any{ - "id": f.nextServerKeyID, "name": body["name"], "active": true, - "key": "ser.mintedKey000000000", "created_at": "2026-07-16T00:00:00Z", - } - f.serverKeys[env] = append(f.serverKeys[env], key) - f.mu.Unlock() - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(key) - }) - mux.HandleFunc("DELETE /api/v1/environments/{api_key}/api-keys/{id}/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - env := r.PathValue("api_key") - id, _ := strconv.Atoi(r.PathValue("id")) - f.mu.Lock() - kept := []map[string]any{} - for _, k := range f.serverKeys[env] { - if k["id"] != id { - kept = append(kept, k) - } - } - f.serverKeys[env] = kept - f.mu.Unlock() - w.WriteHeader(http.StatusNoContent) - }) - mux.HandleFunc("POST /api/v1/environments/{api_key}/clone/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - var body map[string]any - json.NewDecoder(r.Body).Decode(&body) - f.mu.Lock() - f.lastEnvBody = body - proj, src := f.envByAPIKey(r.PathValue("api_key")) - name, _ := body["name"].(string) - clone := map[string]any{"id": 77, "name": name, "api_key": "clonedEnvKey000000000"} - if src != nil { - clone["project"] = src["project"] - f.envs[proj] = append(f.envs[proj], clone) - } - f.mu.Unlock() - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(clone) - }) - mux.HandleFunc("GET /api/v1/projects/{project}/features/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - f.mu.Lock() - f.lastFeatEnv = r.URL.Query().Get("environment") - f.lastFeatSeg = r.URL.Query().Get("segment") - f.lastFeatArch = r.URL.Query().Get("is_archived") - f.lastFeatSearch = r.URL.Query().Get("search") - f.featListCalls++ - items := f.features[r.PathValue("project")] - // Like the backend, search is a case-insensitive contains match on the - // name — deliberately broader than the exact match the CLI wants, so - // tests exercise the client-side narrowing. - if search := r.URL.Query().Get("search"); search != "" { - filtered := []map[string]any{} - for _, it := range items { - name, _ := it["name"].(string) - if strings.Contains(strings.ToLower(name), strings.ToLower(search)) { - filtered = append(filtered, it) - } - } - items = filtered - } - if arch := r.URL.Query().Get("is_archived"); arch != "" { - want := arch == "true" - filtered := []map[string]any{} - for _, it := range items { - a, _ := it["is_archived"].(bool) - if a == want { - filtered = append(filtered, it) - } - } - items = filtered - } - f.mu.Unlock() - json.NewEncoder(w).Encode(map[string]any{ - "count": len(items), "next": nil, "previous": nil, "results": items, - }) - }) - mux.HandleFunc("POST /api/experiments/environments/{env}/update-flag-v2/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - f.mu.Lock() - gated := f.workflowGated - f.mu.Unlock() - if gated { - w.WriteHeader(http.StatusForbidden) - return - } - var body map[string]any - json.NewDecoder(r.Body).Decode(&body) - f.mu.Lock() - f.updateCalls++ - f.lastUpdate = body - f.applyFlagUpdate(body) - f.mu.Unlock() - w.WriteHeader(http.StatusNoContent) - }) - mux.HandleFunc("POST /api/experiments/environments/{env}/delete-segment-override/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - f.mu.Lock() - missing := f.segmentMissing - f.mu.Unlock() - if missing { - w.WriteHeader(http.StatusNotFound) - return - } - var body map[string]any - json.NewDecoder(r.Body).Decode(&body) - f.mu.Lock() - f.lastDelete = body - f.mu.Unlock() - w.WriteHeader(http.StatusNoContent) - }) - // Project retrieve — carries use_edge_identities. - mux.HandleFunc("GET /api/v1/projects/{project}/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - id, _ := strconv.Atoi(r.PathValue("project")) - f.mu.Lock() - f.projGetCalls++ - resp := map[string]any{"id": id, "name": "acme-api", "organisation": 3} - if p := f.projectByID(id); p != nil { - resp = map[string]any{} - for k, v := range p { - resp[k] = v - } - } - resp["use_edge_identities"] = f.useEdge - f.mu.Unlock() - json.NewEncoder(w).Encode(resp) - }) - mux.HandleFunc("PATCH /api/v1/projects/{project}/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - id, _ := strconv.Atoi(r.PathValue("project")) - var body map[string]any - json.NewDecoder(r.Body).Decode(&body) - f.mu.Lock() - f.lastProjectBody = body - p := f.projectByID(id) - if p != nil { - for k, v := range body { - p[k] = v - } - } - f.mu.Unlock() - json.NewEncoder(w).Encode(p) - }) - mux.HandleFunc("DELETE /api/v1/projects/{project}/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - id, _ := strconv.Atoi(r.PathValue("project")) - f.mu.Lock() - for org, list := range f.projects { - kept := list[:0:0] - for _, p := range list { - if p["id"] != id { - kept = append(kept, p) - } - } - f.projects[org] = kept - } - f.mu.Unlock() - w.WriteHeader(http.StatusNoContent) - }) - // Core identities: identifier lookup and create. - mux.HandleFunc("GET /api/v1/environments/{env}/identities/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - q := strings.Trim(r.URL.Query().Get("q"), `"`) - f.mu.Lock() - f.idLookupCalls++ - var results []map[string]any - if id, ok := f.coreIdentities[q]; ok { - results = append(results, map[string]any{"id": id, "identifier": q}) - } - f.mu.Unlock() - json.NewEncoder(w).Encode(map[string]any{"count": len(results), "results": results}) - }) - mux.HandleFunc("POST /api/v1/environments/{env}/identities/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - var body map[string]any - json.NewDecoder(r.Body).Decode(&body) - ident, _ := body["identifier"].(string) - f.mu.Lock() - id := 700 + len(f.coreIdentities) - f.coreIdentities[ident] = id - f.mu.Unlock() - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(map[string]any{"id": id, "identifier": ident}) - }) - // Core identity feature-states: list, create, update, delete. - mux.HandleFunc("GET /api/v1/environments/{env}/identities/{id}/featurestates/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - idID, _ := strconv.Atoi(r.PathValue("id")) - fid, _ := strconv.Atoi(r.URL.Query().Get("feature")) - f.mu.Lock() - var results []map[string]any - if fs := f.coreOverrides[idID][fid]; fs != nil { - results = append(results, map[string]any{"id": fs.id, "enabled": fs.enabled, "feature_state_value": fs.value, "feature": fid}) - } - f.mu.Unlock() - json.NewEncoder(w).Encode(map[string]any{"count": len(results), "results": results}) - }) - mux.HandleFunc("POST /api/v1/environments/{env}/identities/{id}/featurestates/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - idID, _ := strconv.Atoi(r.PathValue("id")) - var body map[string]any - json.NewDecoder(r.Body).Decode(&body) - fid := int(body["feature"].(float64)) - en, _ := body["enabled"].(bool) - f.mu.Lock() - f.lastIdentityWrite = body - if f.coreOverrides[idID] == nil { - f.coreOverrides[idID] = map[int]*fakeFS{} - } - f.nextFSID++ - f.coreOverrides[idID][fid] = &fakeFS{id: f.nextFSID, enabled: en, value: body["feature_state_value"]} - id := f.nextFSID - f.mu.Unlock() - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(map[string]any{"id": id}) - }) - mux.HandleFunc("PUT /api/v1/environments/{env}/identities/{id}/featurestates/{fsid}/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - idID, _ := strconv.Atoi(r.PathValue("id")) - fsID, _ := strconv.Atoi(r.PathValue("fsid")) - var body map[string]any - json.NewDecoder(r.Body).Decode(&body) - en, _ := body["enabled"].(bool) - f.mu.Lock() - f.lastIdentityWrite = body - for _, fs := range f.coreOverrides[idID] { - if fs.id == fsID { - fs.enabled = en - fs.value = body["feature_state_value"] - } - } - f.mu.Unlock() - json.NewEncoder(w).Encode(map[string]any{"id": fsID}) - }) - mux.HandleFunc("DELETE /api/v1/environments/{env}/identities/{id}/featurestates/{fsid}/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - idID, _ := strconv.Atoi(r.PathValue("id")) - fsID, _ := strconv.Atoi(r.PathValue("fsid")) - f.mu.Lock() - for fid, fs := range f.coreOverrides[idID] { - if fs.id == fsID { - delete(f.coreOverrides[idID], fid) - } - } - f.mu.Unlock() - w.WriteHeader(http.StatusNoContent) - }) - // Edge identities: uuid lookup and per-uuid feature-states (read). - mux.HandleFunc("GET /api/v1/environments/{env}/edge-identities/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - q := strings.Trim(r.URL.Query().Get("q"), `"`) - f.mu.Lock() - f.edgeLookups++ - var results []map[string]any - if _, ok := f.edgeOverrides[q]; ok { - results = append(results, map[string]any{"identity_uuid": "uuid-" + q, "identifier": q}) - } - f.mu.Unlock() - json.NewEncoder(w).Encode(map[string]any{"count": len(results), "results": results}) - }) - mux.HandleFunc("GET /api/v1/environments/{env}/edge-identities/{uuid}/edge-featurestates/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - identifier := strings.TrimPrefix(r.PathValue("uuid"), "uuid-") - fid, _ := strconv.Atoi(r.URL.Query().Get("feature")) - f.mu.Lock() - var results []map[string]any - if fs := f.edgeOverrides[identifier][fid]; fs != nil { - results = append(results, map[string]any{"enabled": fs.enabled, "feature_state_value": fs.value, "feature": fid, "featurestate_uuid": "fsu"}) - } - f.mu.Unlock() - json.NewEncoder(w).Encode(map[string]any{"count": len(results), "results": results}) - }) - // Edge identifier-based feature-states (note the double environments, no slash). - mux.HandleFunc("PUT /api/v1/environments/environments/{env}/edge-identities-featurestates", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - var body map[string]any - json.NewDecoder(r.Body).Decode(&body) - ident, _ := body["identifier"].(string) - fid := int(body["feature"].(float64)) - en, _ := body["enabled"].(bool) - f.mu.Lock() - f.lastEdgeWrite = body - if f.edgeOverrides[ident] == nil { - f.edgeOverrides[ident] = map[int]*fakeFS{} - } - f.edgeOverrides[ident][fid] = &fakeFS{enabled: en, value: body["feature_state_value"]} - f.mu.Unlock() - json.NewEncoder(w).Encode(map[string]any{"feature": fid, "enabled": en, "feature_state_value": body["feature_state_value"]}) - }) - mux.HandleFunc("DELETE /api/v1/environments/environments/{env}/edge-identities-featurestates", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - var body map[string]any - json.NewDecoder(r.Body).Decode(&body) - ident, _ := body["identifier"].(string) - f.mu.Lock() - f.lastEdgeDelete = body - if fv, ok := body["feature"].(float64); ok { - delete(f.edgeOverrides[ident], int(fv)) - } - f.mu.Unlock() - w.WriteHeader(http.StatusNoContent) - }) - // Organisation CRUD (the list route handles GET /organisations/). - mux.HandleFunc("GET /api/v1/organisations/{id}/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - id, _ := strconv.Atoi(r.PathValue("id")) - f.mu.Lock() - o := f.orgByID(id) - f.mu.Unlock() - if o == nil { - w.WriteHeader(http.StatusNotFound) - return - } - json.NewEncoder(w).Encode(o) - }) - mux.HandleFunc("POST /api/v1/organisations/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - var body map[string]any - json.NewDecoder(r.Body).Decode(&body) - f.mu.Lock() - f.lastOrgBody = body - f.nextOrgID++ - body["id"] = f.nextOrgID - f.orgs = append(f.orgs, body) - f.mu.Unlock() - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(body) - }) - mux.HandleFunc("PATCH /api/v1/organisations/{id}/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - id, _ := strconv.Atoi(r.PathValue("id")) - var body map[string]any - json.NewDecoder(r.Body).Decode(&body) - f.mu.Lock() - f.lastOrgBody = body - o := f.orgByID(id) - if o != nil { - for k, v := range body { - o[k] = v - } - } - f.mu.Unlock() - json.NewEncoder(w).Encode(o) - }) - mux.HandleFunc("DELETE /api/v1/organisations/{id}/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - id, _ := strconv.Atoi(r.PathValue("id")) - f.mu.Lock() - kept := f.orgs[:0:0] - for _, o := range f.orgs { - if o["id"] != id { - kept = append(kept, o) - } - } - f.orgs = kept - f.mu.Unlock() - w.WriteHeader(http.StatusNoContent) - }) - // Feature retrieve (feature CRUD; the list route is shared with flags). - mux.HandleFunc("GET /api/v1/projects/{project}/features/{id}/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - id, _ := strconv.Atoi(r.PathValue("id")) - f.mu.Lock() - var found map[string]any - for _, it := range f.features[r.PathValue("project")] { - if it["id"] == id { - found = it - break - } - } - f.mu.Unlock() - if found == nil { - w.WriteHeader(http.StatusNotFound) - return - } - json.NewEncoder(w).Encode(found) - }) - // Feature create/update/delete. - mux.HandleFunc("POST /api/v1/projects/{project}/features/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - var body map[string]any - json.NewDecoder(r.Body).Decode(&body) - project := r.PathValue("project") - f.mu.Lock() - f.lastFeatureBody = body - f.nextFeatureID++ - body["id"] = f.nextFeatureID - f.features[project] = append(f.features[project], body) - f.mu.Unlock() - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(body) - }) - mux.HandleFunc("PATCH /api/v1/projects/{project}/features/{id}/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - id, _ := strconv.Atoi(r.PathValue("id")) - var body map[string]any - json.NewDecoder(r.Body).Decode(&body) - f.mu.Lock() - f.lastFeatureBody = body - var found map[string]any - for _, it := range f.features[r.PathValue("project")] { - if it["id"] == id { - for k, v := range body { - it[k] = v - } - found = it - } - } - f.mu.Unlock() - if found == nil { - w.WriteHeader(http.StatusNotFound) - return - } - json.NewEncoder(w).Encode(found) - }) - mux.HandleFunc("DELETE /api/v1/projects/{project}/features/{id}/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - id, _ := strconv.Atoi(r.PathValue("id")) - project := r.PathValue("project") - f.mu.Lock() - kept := f.features[project][:0:0] - for _, it := range f.features[project] { - if it["id"] != id { - kept = append(kept, it) - } - } - f.features[project] = kept - f.mu.Unlock() - w.WriteHeader(http.StatusNoContent) - }) - // Multivariate options sub-resource. - mux.HandleFunc("POST /api/v1/projects/{project}/features/{feature}/mv-options/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - fid, _ := strconv.Atoi(r.PathValue("feature")) - var body map[string]any - json.NewDecoder(r.Body).Decode(&body) - f.mu.Lock() - f.lastMVBody = body - f.nextMVID++ - body["id"] = f.nextMVID - if feat := f.featureByID(r.PathValue("project"), fid); feat != nil { - opts, _ := feat["multivariate_options"].([]any) - feat["multivariate_options"] = append(opts, body) - } - f.mu.Unlock() - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(body) - }) - mux.HandleFunc("PATCH /api/v1/projects/{project}/features/{feature}/mv-options/{id}/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - fid, _ := strconv.Atoi(r.PathValue("feature")) - oid, _ := strconv.Atoi(r.PathValue("id")) - var body map[string]any - json.NewDecoder(r.Body).Decode(&body) - f.mu.Lock() - f.lastMVBody = body - var found map[string]any - if feat := f.featureByID(r.PathValue("project"), fid); feat != nil { - for _, o := range feat["multivariate_options"].([]any) { - om := o.(map[string]any) - if om["id"] == oid { - for k, v := range body { - om[k] = v - } - found = om - } - } - } - f.mu.Unlock() - json.NewEncoder(w).Encode(found) - }) - mux.HandleFunc("DELETE /api/v1/projects/{project}/features/{feature}/mv-options/{id}/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - fid, _ := strconv.Atoi(r.PathValue("feature")) - oid, _ := strconv.Atoi(r.PathValue("id")) - f.mu.Lock() - if feat := f.featureByID(r.PathValue("project"), fid); feat != nil { - opts, _ := feat["multivariate_options"].([]any) - kept := []any{} - for _, o := range opts { - if o.(map[string]any)["id"] != oid { - kept = append(kept, o) - } - } - feat["multivariate_options"] = kept - } - f.mu.Unlock() - w.WriteHeader(http.StatusNoContent) - }) - // Segments: list, retrieve, create, update, delete. - mux.HandleFunc("GET /api/v1/projects/{project}/segments/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - include := r.URL.Query().Get("include_feature_specific") == "true" - f.mu.Lock() - f.segListCalls++ - results := []map[string]any{} - for _, s := range f.segments { - if !include && s["feature"] != nil { - continue - } - results = append(results, s) - } - f.mu.Unlock() - json.NewEncoder(w).Encode(map[string]any{"count": len(results), "results": results}) - }) - mux.HandleFunc("POST /api/v1/projects/{project}/segments/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - var body map[string]any - json.NewDecoder(r.Body).Decode(&body) - f.mu.Lock() - f.lastSegmentBody = body - f.nextSegmentID++ - id := f.nextSegmentID - body["id"] = id - f.segments[id] = body - f.mu.Unlock() - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(body) - }) - mux.HandleFunc("GET /api/v1/projects/{project}/segments/{id}/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - id, _ := strconv.Atoi(r.PathValue("id")) - f.mu.Lock() - s := f.segments[id] - f.mu.Unlock() - if s == nil { - w.WriteHeader(http.StatusNotFound) - return - } - json.NewEncoder(w).Encode(s) - }) - mux.HandleFunc("PUT /api/v1/projects/{project}/segments/{id}/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - id, _ := strconv.Atoi(r.PathValue("id")) - var body map[string]any - json.NewDecoder(r.Body).Decode(&body) - f.mu.Lock() - f.lastSegmentBody = body - body["id"] = id - f.segments[id] = body - f.mu.Unlock() - json.NewEncoder(w).Encode(body) - }) - mux.HandleFunc("DELETE /api/v1/projects/{project}/segments/{id}/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - id, _ := strconv.Atoi(r.PathValue("id")) - f.mu.Lock() - delete(f.segments, id) - f.mu.Unlock() - w.WriteHeader(http.StatusNoContent) - }) - // feature-segments lists a feature's segment overrides (priority + segment - // name metadata), ordered by priority, for one environment. - mux.HandleFunc("GET /api/v1/features/feature-segments/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - if r.URL.Query().Get("environment") == "" || r.URL.Query().Get("feature") == "" { - w.WriteHeader(http.StatusBadRequest) // both are required upstream - return - } - f.mu.Lock() - f.fsListCalls++ - f.fsInFlight++ - if f.fsInFlight > f.fsPeak { - f.fsPeak = f.fsInFlight - } - delay := f.fsDelay - rows := f.featureSegments[r.URL.Query().Get("feature")] - f.mu.Unlock() - if delay > 0 { - time.Sleep(delay) - } - f.mu.Lock() - f.fsInFlight-- - f.mu.Unlock() - if rows == nil { - rows = []map[string]any{} - } - json.NewEncoder(w).Encode(map[string]any{"count": len(rows), "results": rows}) - }) - // featurestates lists a feature's states in one environment (the default - // plus one row per segment override), with the typed value wire form. - mux.HandleFunc("GET /api/v1/features/featurestates/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - if r.URL.Query().Get("environment") == "" { - w.WriteHeader(http.StatusBadRequest) // required upstream - return - } - f.mu.Lock() - f.stListCalls++ - rows := f.featureStates[r.URL.Query().Get("feature")] - f.mu.Unlock() - if rows == nil { - rows = []map[string]any{} - } - json.NewEncoder(w).Encode(map[string]any{"count": len(rows), "results": rows}) - }) - // The environment featurestates list with ?anyIdentity= is how core - // identity overrides for one feature are enumerated. - mux.HandleFunc("GET /api/v1/environments/{env}/featurestates/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - if r.URL.Query().Get("anyIdentity") == "" { - w.WriteHeader(http.StatusBadRequest) // the CLI only uses the identity mode - return - } - featureID, _ := strconv.Atoi(r.URL.Query().Get("feature")) - f.mu.Lock() - identifiers := make([]string, 0, len(f.coreIdentities)) - for identifier := range f.coreIdentities { - identifiers = append(identifiers, identifier) - } - sort.Strings(identifiers) - results := []map[string]any{} - for _, identifier := range identifiers { - id := f.coreIdentities[identifier] - if fs := f.coreOverrides[id][featureID]; fs != nil { - results = append(results, map[string]any{ - "id": fs.id, "enabled": fs.enabled, "feature_state_value": fs.value, - "identity": map[string]any{"id": id, "identifier": identifier}, - "feature": featureID, - }) - } - } - f.mu.Unlock() - json.NewEncoder(w).Encode(map[string]any{"count": len(results), "results": results}) - }) - // Edge identity overrides: no trailing slash, no pagination. - mux.HandleFunc("GET /api/v1/environments/{env}/edge-identity-overrides", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) { - w.WriteHeader(http.StatusUnauthorized) - return - } - featureID, _ := strconv.Atoi(r.URL.Query().Get("feature")) - f.mu.Lock() - identifiers := make([]string, 0, len(f.edgeOverrides)) - for identifier := range f.edgeOverrides { - identifiers = append(identifiers, identifier) - } - sort.Strings(identifiers) - results := []map[string]any{} - for _, identifier := range identifiers { - if fs := f.edgeOverrides[identifier][featureID]; fs != nil { - results = append(results, map[string]any{ - "identifier": identifier, - "feature_state": map[string]any{ - "enabled": fs.enabled, "feature_state_value": fs.value, "feature": featureID, - }, - }) - } - } - f.mu.Unlock() - json.NewEncoder(w).Encode(map[string]any{"results": results}) - }) - // The SDK API: the two endpoints the Flagsmith SDK evaluates flags over. - mux.HandleFunc("GET /api/v1/flags/", func(w http.ResponseWriter, r *http.Request) { - f.mu.Lock() - f.sdkUserAgents = append(f.sdkUserAgents, r.Header.Get("User-Agent")) - f.sdkKeys = append(f.sdkKeys, r.Header.Get("X-Environment-Key")) - status, flags, delay := f.sdkStatus, f.sdkEnvFlags[r.Header.Get("X-Environment-Key")], f.sdkDelay - f.mu.Unlock() - time.Sleep(delay) - if status != 0 { - w.WriteHeader(status) - return - } - if flags == nil { - w.WriteHeader(http.StatusUnauthorized) - return - } - json.NewEncoder(w).Encode(flags) - }) - mux.HandleFunc("POST /api/v1/identities/", func(w http.ResponseWriter, r *http.Request) { - var body map[string]any - json.NewDecoder(r.Body).Decode(&body) - identifier, _ := body["identifier"].(string) - f.mu.Lock() - f.sdkUserAgents = append(f.sdkUserAgents, r.Header.Get("User-Agent")) - f.sdkKeys = append(f.sdkKeys, r.Header.Get("X-Environment-Key")) - f.lastIdentify = body - status, flags, delay := f.sdkStatus, f.sdkEnvFlags[r.Header.Get("X-Environment-Key")], f.sdkDelay - if override, ok := f.sdkIdentityFlags[identifier]; ok { - flags = override - } - f.mu.Unlock() - time.Sleep(delay) - if status != 0 { - w.WriteHeader(status) - return - } - if flags == nil { - w.WriteHeader(http.StatusUnauthorized) - return - } - json.NewEncoder(w).Encode(map[string]any{ - "identifier": identifier, "traits": body["traits"], "flags": flags, - }) - }) - // echo reflects the request back, for exercising `flagsmith api`. - mux.HandleFunc("/api/v1/echo/", func(w http.ResponseWriter, r *http.Request) { - if !authorized(r) && r.Header.Get("X-Environment-Key") == "" { - w.WriteHeader(http.StatusUnauthorized) - return - } - body, _ := io.ReadAll(r.Body) - json.NewEncoder(w).Encode(map[string]any{ - "method": r.Method, - "path": r.URL.Path, - "query": r.URL.RawQuery, - "authorization": r.Header.Get("Authorization"), - "envkey": r.Header.Get("X-Environment-Key"), - "content_type": r.Header.Get("Content-Type"), - "custom": r.Header.Get("X-Custom"), - "body": string(body), +func TestBrowserLoginFlow(t *testing.T) { + for _, shape := range []struct { + name string + prefix []string + }{ + {"top-level", nil}, + {"auth alias", []string{"auth"}}, + } { + t.Run(shape.name, func(t *testing.T) { + testBrowserLoginFlow(t, shape.prefix) }) - }) - f.srv = httptest.NewServer(mux) - t.Cleanup(f.srv.Close) - return f -} - -// applyFlagUpdate mutates the stored features to reflect an update-flag-v2 -// body, so a re-fetch after the mutation sees the new state. Called under lock. -func (f *fakeInstance) applyFlagUpdate(body map[string]any) { - feature, _ := body["feature"].(map[string]any) - name, _ := feature["name"].(string) - def, _ := body["environment_default"].(map[string]any) - enabled, _ := def["enabled"].(bool) - val, _ := def["value"].(map[string]any) - overrides, _ := body["segment_overrides"].([]any) - for _, items := range f.features { - for _, item := range items { - if item["name"] != name { - continue - } - state, _ := item["environment_feature_state"].(map[string]any) - if state == nil { - state = map[string]any{} - item["environment_feature_state"] = state - } - state["enabled"] = enabled - state["feature_state_value"] = scalarFromWire(val) - featureKey := "" - if id, ok := item["id"].(int); ok { - featureKey = strconv.Itoa(id) - } - for _, o := range overrides { - ov, _ := o.(map[string]any) - segEnabled, _ := ov["enabled"].(bool) - segVal, _ := ov["value"].(map[string]any) - item["segment_feature_state"] = map[string]any{ - "enabled": segEnabled, "feature_state_value": scalarFromWire(segVal), - } - // A priority write moves the feature-segment row, so a re-fetch - // sees the new order. - if prio, ok := ov["priority"].(float64); ok { - for _, row := range f.featureSegments[featureKey] { - if seg, _ := row["segment"].(int); float64(seg) == ov["segment_id"] { - row["priority"] = int(prio) - } - } - } - } - sort.SliceStable(f.featureSegments[featureKey], func(a, b int) bool { - pa, _ := f.featureSegments[featureKey][a]["priority"].(int) - pb, _ := f.featureSegments[featureKey][b]["priority"].(int) - return pa < pb - }) - } - } -} - -// scalarFromWire turns an update-flag-v2 {type,value} into the bare scalar the -// features list would report. -func scalarFromWire(val map[string]any) any { - t, _ := val["type"].(string) - v, _ := val["value"].(string) - switch t { - case "integer": - n, _ := strconv.Atoi(v) - return n - case "boolean": - return v == "true" - default: - return v } } -func (f *fakeInstance) revokeCount() int { - f.mu.Lock() - defer f.mu.Unlock() - return len(f.revoked) -} - -// featuresEnv returns the ?environment= value the /features/ endpoint last saw. -func (f *fakeInstance) featuresEnv() string { - f.mu.Lock() - defer f.mu.Unlock() - return f.lastFeatEnv -} - -// featuresSeg returns the ?segment= value the /features/ endpoint last saw. -func (f *fakeInstance) featuresSeg() string { - f.mu.Lock() - defer f.mu.Unlock() - return f.lastFeatSeg -} - -// featuresSearch returns the ?search= value the /features/ endpoint last saw. -func (f *fakeInstance) featuresSearch() string { - f.mu.Lock() - defer f.mu.Unlock() - return f.lastFeatSearch -} - -// featuresCalls returns how many times the /features/ list endpoint was hit. -func (f *fakeInstance) featuresCalls() int { - f.mu.Lock() - defer f.mu.Unlock() - return f.featListCalls -} - -// featureSegmentsCalls returns how many times feature-segments was hit. -func (f *fakeInstance) featureSegmentsCalls() int { - f.mu.Lock() - defer f.mu.Unlock() - return f.fsListCalls -} - -// featureSegmentsPeak returns the most feature-segments requests that were -// ever in flight at once. -func (f *fakeInstance) featureSegmentsPeak() int { - f.mu.Lock() - defer f.mu.Unlock() - return f.fsPeak -} - -// segmentsCalls returns how many times the /segments/ list endpoint was hit. -func (f *fakeInstance) segmentsCalls() int { - f.mu.Lock() - defer f.mu.Unlock() - return f.segListCalls -} - -// featureStatesCalls returns how many times featurestates was hit. -func (f *fakeInstance) featureStatesCalls() int { - f.mu.Lock() - defer f.mu.Unlock() - return f.stListCalls -} - -// identityLookups returns how many identifier→id lookups the core -// identities endpoint served. -func (f *fakeInstance) identityLookups() int { - f.mu.Lock() - defer f.mu.Unlock() - return f.idLookupCalls -} - -// edgeIdentityLookups returns how many identifier→uuid lookups the edge -// identities endpoint served. -func (f *fakeInstance) edgeIdentityLookups() int { - f.mu.Lock() - defer f.mu.Unlock() - return f.edgeLookups -} - -// environmentGets returns how many single-environment retrieves were served. -func (f *fakeInstance) environmentGets() int { - f.mu.Lock() - defer f.mu.Unlock() - return f.envGetCalls -} - -// projectGets returns how many single-project retrieves were served. -func (f *fakeInstance) projectGets() int { - f.mu.Lock() - defer f.mu.Unlock() - return f.projGetCalls -} - -// organisationLists returns how many organisation list calls were served. -func (f *fakeInstance) organisationLists() int { - f.mu.Lock() - defer f.mu.Unlock() - return f.orgListCalls -} - -// defaultFeatures is the stock project features list (with per-environment -// state embedded) returned by the fake /features/ endpoint. -func defaultFeatures() []map[string]any { - return []map[string]any{ - { - "id": 1, "name": "onboarding_banner", "type": "STANDARD", - "description": "Welcome banner", "lifecycle_stage": "live", - "num_segment_overrides": 0, "num_identity_overrides": 0, - "code_references_counts": []any{}, - "environment_feature_state": map[string]any{"enabled": true, "feature_state_value": nil}, - }, - { - "id": 2, "name": "max_items", "type": "STANDARD", - "num_segment_overrides": 1, "num_identity_overrides": 2, - "code_references_counts": []any{map[string]any{"count": 3}}, - "environment_feature_state": map[string]any{"enabled": false, "feature_state_value": 25}, - }, - } -} - -// sdkFlagsFrom renders admin feature fixtures as the SDK API's flags payload — -// the shape `flagsmith evaluate` reads. -func sdkFlagsFrom(features []map[string]any) []map[string]any { - flags := make([]map[string]any, 0, len(features)) - for _, item := range features { - state, _ := item["environment_feature_state"].(map[string]any) - flags = append(flags, map[string]any{ - "enabled": state["enabled"], - "feature_state_value": state["feature_state_value"], - "feature": map[string]any{"id": item["id"], "name": item["name"]}, - }) - } - return flags -} - -func (f *fakeInstance) identifyBody() map[string]any { - f.mu.Lock() - defer f.mu.Unlock() - return f.lastIdentify -} - -func (f *fakeInstance) sdkAgents() []string { - f.mu.Lock() - defer f.mu.Unlock() - return append([]string(nil), f.sdkUserAgents...) -} - -func (f *fakeInstance) sdkSentKeys() []string { - f.mu.Lock() - defer f.mu.Unlock() - return append([]string(nil), f.sdkKeys...) -} - -func (f *fakeInstance) environmentLists() int { - f.mu.Lock() - defer f.mu.Unlock() - return f.envListCalls -} - -func (f *fakeInstance) refreshCount() int { - f.mu.Lock() - defer f.mu.Unlock() - return f.tokenPosts -} - -// A server-side key is a secret — and FLAGSMITH_ENVIRONMENT_KEY is exactly -// where hintServerSideKey tells users to put it. It can never resolve an -// environment over the Admin API (the list carries client-side keys only), -// and its value must never be echoed into an error: that lands it in CI logs. -func TestServerSideKeyNeverEchoed(t *testing.T) { - t.Run("the FLAGSMITH_ENVIRONMENT_KEY fallback names the variable, not the value", func(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - root := tempRepo(t) - writeConfig(t, root, `{"project": 101, "apiUrl": "`+f.srv.URL+`"}`) - setMasterKey(t, f.srv.URL) - // sdkApiUrl follows the config's non-default apiUrl, so the SDK - // credential is scoped to that host. - setEnvCred(t, envEnvironmentKey, f.srv.URL, "ser.SuperSecret123") - - // When - _, err := run("", "flag", "list") - - // Then - if err == nil { - t.Fatal("err = nil, want an error") - } - if strings.Contains(err.Error(), "SuperSecret123") || strings.Contains(hintFor(err), "SuperSecret123") { - t.Errorf("err = %v — the server-side key leaked", err) - } - if !strings.Contains(err.Error(), "FLAGSMITH_ENVIRONMENT_KEY") { - t.Errorf("err = %v, want it to name the variable", err) - } - }) - - t.Run("a key the SDK API rejects is not echoed", func(t *testing.T) { - // Given a key the SDK surface does not know, so evaluation gets a 401. - isolateStorage(t) - f := newFakeInstance(t) - root := tempRepo(t) - writeConfig(t, root, `{"apiUrl": "`+f.srv.URL+`"}`) - setEnvCred(t, envEnvironmentKey, f.srv.URL, "ser.SuperSecret123") - - // When - out, err := run("", "evaluate") - - // Then - if err == nil { - t.Fatal("err = nil, want an error") - } - // out already carries the rendered error, but assert on the error itself - // too rather than lean on cobra printing it into the same buffer. - if strings.Contains(out, "SuperSecret123") || - strings.Contains(err.Error(), "SuperSecret123") || - strings.Contains(hintFor(err), "SuperSecret123") { - t.Errorf("output = %q — the server-side key leaked", out) - } - }) - - t.Run("a positional ser. ref is refused without echoing it", func(t *testing.T) { - // Given - flagUpdateEnv(t) - - // When - _, err := run("", "environment", "get", "ser.SuperSecret123") - - // Then - if err == nil { - t.Fatal("err = nil, want an error") - } - if strings.Contains(err.Error(), "SuperSecret123") { - t.Errorf("err = %v — the server-side key leaked", err) - } - if !strings.Contains(hintFor(err), "FLAGSMITH_ENVIRONMENT_KEY") { - t.Errorf("hint = %q, want the server-side-key hint", hintFor(err)) - } - }) -} - -func TestFlagListResolvesEnvironmentName(t *testing.T) { - t.Run("name resolved to its id for the features query", func(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - root := tempRepo(t) - writeConfig(t, root, `{"project": 101, "environment": "Development", "apiUrl": "`+f.srv.URL+`"}`) - setMasterKey(t, f.srv.URL) - - // When - out, err := run("", "flag", "list") - - // Then - if err != nil { - t.Fatalf("flag list: %v\noutput: %s", err, out) - } - if got := f.featuresEnv(); got != "1" { - t.Errorf("features environment = %q, want the resolved Development id (1)", got) - } - }) - - t.Run("without credentials the command errors", func(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - root := tempRepo(t) - writeConfig(t, root, `{"project": 101, "environment": "Development", "apiUrl": "`+f.srv.URL+`"}`) - - // When / Then - if _, err := run("", "flag", "list"); !errors.Is(err, auth.ErrNotLoggedIn) { - t.Errorf("err = %v, want ErrNotLoggedIn", err) - } - }) - - t.Run("ambiguous name exits 2 without a TTY", func(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - f.envs["101"] = []map[string]any{ - {"id": 1, "name": "Staging", "api_key": "stagingKeyA0000000000"}, - {"id": 2, "name": "Staging", "api_key": "stagingKeyB0000000000"}, - } - root := tempRepo(t) - writeConfig(t, root, `{"project": 101, "environment": "Staging", "apiUrl": "`+f.srv.URL+`"}`) - setMasterKey(t, f.srv.URL) - - // When / Then - _, err := run("", "flag", "list") - var ue *usageError - if !errors.As(err, &ue) || !strings.Contains(err.Error(), "use its key instead") { - t.Errorf("err = %v, want an ambiguity usage error offering the key (exit 2)", err) - } - }) - - t.Run("unknown environment name hints at environment list", func(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - root := tempRepo(t) - writeConfig(t, root, `{"project": 101, "environment": "Nope", "apiUrl": "`+f.srv.URL+`"}`) - setMasterKey(t, f.srv.URL) - - // When / Then - _, err := run("", "flag", "list") - if err == nil || !strings.Contains(hintFor(err), "flagsmith environment list") { - t.Errorf("err = %v (hint %q), want a hint offering `flagsmith environment list`", err, hintFor(err)) - } - }) - - t.Run("a key reference resolves to its environment", func(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - root := tempRepo(t) - writeConfig(t, root, `{"project": 101, "environment": "K2mVsGdXhZ8kQqZ9pJmNbJ", "apiUrl": "`+f.srv.URL+`"}`) - setMasterKey(t, f.srv.URL) - - // When - if _, err := run("", "flag", "list"); err != nil { - t.Fatalf("flag list: %v", err) - } - - // Then - if got := f.featuresEnv(); got != "2" { - t.Errorf("features environment = %q, want the Production id (2)", got) - } - }) -} - -// commandShapes are the two supported spellings of login/logout: -// top-level and under `auth`. -var commandShapes = []struct { - name string - prefix []string -}{ - {"top-level", nil}, - {"auth alias", []string{"auth"}}, -} - -// shapeArgs prepends a shape prefix to a command line. -func shapeArgs(prefix []string, args ...string) []string { - return append(append([]string{}, prefix...), args...) -} - -func TestBrowserLoginFlow(t *testing.T) { - for _, shape := range commandShapes { - t.Run(shape.name, func(t *testing.T) { - testBrowserLoginFlow(t, shape.prefix) - }) - } -} - -func testBrowserLoginFlow(t *testing.T, prefix []string) { - // Given - isolateStorage(t) - f := newFakeInstance(t) +func testBrowserLoginFlow(t *testing.T, prefix []string) { + // Given + isolateStorage(t) + f := newFakeInstance(t) // When resetFlags() @@ -1804,5559 +161,416 @@ func testBrowserLoginFlow(t *testing.T, prefix []string) { rootCmd.SetIn(strings.NewReader("")) rootCmd.SetArgs(shapeArgs(prefix, "login", "--api", f.srv.URL, "--no-browser")) done := make(chan error, 1) - go func() { done <- rootCmd.Execute() }() - - authURLPattern := regexp.MustCompile(`https?://\S+/oauth/authorize/\?\S+`) - var q url.Values - deadline := time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) && q == nil { - if m := authURLPattern.FindString(out.String()); m != "" { - u, err := url.Parse(m) - if err != nil { - t.Fatal(err) - } - q = u.Query() - break - } - time.Sleep(10 * time.Millisecond) - } - if q == nil { - t.Fatalf("login never printed an authorization URL; output: %q", out.String()) - } - if _, err := http.Get(q.Get("redirect_uri") + "?code=c&state=" + url.QueryEscape(q.Get("state"))); err != nil { - t.Fatal(err) - } - - // Then - select { - case err := <-done: - if err != nil { - t.Fatalf("login: %v\noutput: %s", err, out.String()) - } - case <-time.After(10 * time.Second): - t.Fatal("login did not return") - } - if got := out.String(); !strings.Contains(got, "✓ Logged in to "+f.srv.URL+" as kim@example.com") || - !strings.Contains(got, "keychain") { - t.Errorf("login output = %q", got) - } - - // When - statusOut, err := run("", "auth", "status", "--api", f.srv.URL) - - // Then - if err != nil { - t.Fatalf("auth status: %v", err) - } - for _, want := range []string{"kim@example.com", "keychain", "Acme"} { - if !strings.Contains(statusOut, want) { - t.Errorf("auth status output = %q, want it to contain %q", statusOut, want) - } - } - - // When - tokenOut, err := run("", "auth", "token", "--api", f.srv.URL) - - // Then - if err != nil { - t.Fatalf("auth token: %v", err) - } - if strings.TrimSpace(tokenOut) != oauthAccess { - t.Errorf("auth token output = %q, want the access token", tokenOut) - } - - // When - logoutOut, err := run("", shapeArgs(prefix, "logout", "--api", f.srv.URL)...) - - // Then - if err != nil { - t.Fatalf("logout: %v", err) - } - if !strings.Contains(logoutOut, "Logged out of "+f.srv.URL) { - t.Errorf("logout output = %q", logoutOut) - } - if f.revokeCount() != 1 { - t.Errorf("revocations = %d, want 1", f.revokeCount()) - } - - // When / Then - if _, err := run("", "auth", "status", "--api", f.srv.URL); !errors.Is(err, auth.ErrNotLoggedIn) { - t.Errorf("auth status after logout = %v, want ErrNotLoggedIn", err) - } - - // When / Then - logoutOut, err = run("", shapeArgs(prefix, "logout", "--api", f.srv.URL)...) - if err != nil || !strings.Contains(logoutOut, "Not logged in") { - t.Errorf("second logout = (%q, %v), want a friendly no-op", logoutOut, err) - } -} - -func TestAPIFlagWorksInAnyPosition(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - setMasterKey(t, f.srv.URL) - - // When / Then - var outputs []string - for _, args := range [][]string{ - {"auth", "status", "--api", f.srv.URL}, // after the subcommand - {"--api", f.srv.URL, "auth", "status"}, // before the subcommand - {"auth", "--api", f.srv.URL, "status"}, // between nested subcommands - } { - out, err := run("", args...) - if err != nil { - t.Fatalf("%v: %v", args, err) - } - outputs = append(outputs, out) - } - if outputs[0] != outputs[1] || outputs[1] != outputs[2] { - t.Errorf("flag position changed behaviour:\n%q\n%q\n%q", outputs[0], outputs[1], outputs[2]) - } - if !strings.Contains(outputs[0], "Acme") { - t.Errorf("output = %q, want the fake instance to have been hit", outputs[0]) - } -} - -func TestAPIURLFlagWithHiddenAlias(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - setMasterKey(t, f.srv.URL) - - // When - canonical, err := run("", "auth", "status", "--api-url", f.srv.URL) - - // Then - if err != nil { - t.Fatalf("--api-url: %v", err) - } - if !strings.Contains(canonical, "Acme") { - t.Errorf("output = %q, want the fake instance to have been hit", canonical) - } - - // When - alias, err := run("", "auth", "status", "--api", f.srv.URL) - - // Then - if err != nil { - t.Fatalf("--api alias: %v", err) - } - if alias != canonical { - t.Errorf("alias output differs from canonical:\n%q\n%q", alias, canonical) - } - - // When / Then - helpOut, err := run("", "--help") - if err != nil { - t.Fatalf("--help: %v", err) - } - if !strings.Contains(helpOut, "--api-url") { - t.Errorf("help = %q, want --api-url documented", helpOut) - } - if strings.Contains(helpOut, "--api ") { - t.Errorf("help = %q, want the --api alias hidden", helpOut) - } -} - -// tempRepo creates a git repo dir, chdirs into it, and returns its path. -func tempRepo(t *testing.T) string { - t.Helper() - root := t.TempDir() - if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil { - t.Fatal(err) - } - t.Chdir(root) - return root -} - -func writeConfig(t *testing.T, dir, content string) string { - t.Helper() - path := filepath.Join(dir, "flagsmith.json") - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { - t.Fatal(err) - } - return path -} - -func configJSON(t *testing.T, args ...string) map[string]map[string]any { - t.Helper() - out, err := run("", append([]string{"config", "--json"}, args...)...) - if err != nil { - t.Fatalf("config --json: %v\noutput: %s", err, out) - } - parsed := map[string]map[string]any{} - if err := json.Unmarshal([]byte(out), &parsed); err != nil { - t.Fatalf("parsing %q: %v", out, err) - } - return parsed -} - -func TestConfigCommand(t *testing.T) { - t.Run("defaults with no config file", func(t *testing.T) { - // Given - isolateStorage(t) - tempRepo(t) - - // When - got := configJSON(t) - - // Then - if v := got["apiUrl"]; v["value"] != "https://api.flagsmith.com" || v["source"] != "default" { - t.Errorf("apiUrl = %v", v) - } - if v := got["sdkApiUrl"]; v["value"] != "https://edge.api.flagsmith.com" || v["source"] != "default" { - t.Errorf("sdkApiUrl = %v", v) - } - if v := got["configPath"]; v["value"] != nil { - t.Errorf("configPath = %v, want null", v) - } - if v := got["project"]; v["value"] != nil { - t.Errorf("project = %v, want null", v) - } - }) - - t.Run("file, env, cli sources with cache names", func(t *testing.T) { - // Given - isolateStorage(t) - root := tempRepo(t) - path := writeConfig(t, root, `{ - "project": 12345, - "organisation": 3, - "environment": "K2mVsGdXhZ8kQqZ9pJmNbJ", - "apiUrl": "https://acme.example" - }`) - if err := cache.Merge("https://acme.example", &cache.Names{ - Organisations: map[string]string{"3": "Acme"}, - Projects: map[string]string{"12345": "my-app"}, - Environments: map[string]string{"StagingKey123": "Staging"}, - }); err != nil { - t.Fatal(err) - } - t.Setenv("FLAGSMITH_ENVIRONMENT", "StagingKey123") - - // When - got := configJSON(t, "--sdk-api-url", "https://flags.example") - - // Then - if v := got["configPath"]; v["value"] != path || v["source"] != "default" { - t.Errorf("configPath = %v", v) - } - if v := got["project"]; v["value"] != float64(12345) || v["name"] != "my-app" || v["source"] != "config" { - t.Errorf("project = %v", v) - } - if v := got["organisation"]; v["value"] != float64(3) || v["name"] != "Acme" || v["source"] != "config" { - t.Errorf("organisation = %v", v) - } - if v := got["environment"]; v["value"] != "StagingKey123" || v["name"] != "Staging" || v["source"] != "env" { - t.Errorf("environment = %v", v) - } - if v := got["apiUrl"]; v["value"] != "https://acme.example" || v["source"] != "config" { - t.Errorf("apiUrl = %v", v) - } - if v := got["sdkApiUrl"]; v["value"] != "https://flags.example" || v["source"] != "cli" { - t.Errorf("sdkApiUrl = %v", v) - } - }) - - t.Run("sdk api url follows a set api url", func(t *testing.T) { - // Given - isolateStorage(t) - tempRepo(t) - - // When - got := configJSON(t, "--api-url", "https://self.example") - - // Then - if v := got["apiUrl"]; v["source"] != "cli" { - t.Errorf("apiUrl = %v", v) - } - if v := got["sdkApiUrl"]; v["value"] != "https://self.example" || v["source"] != "default" { - t.Errorf("sdkApiUrl = %v, want it to follow apiUrl as default", v) - } - }) - - t.Run("explicit config path via flag", func(t *testing.T) { - // Given - isolateStorage(t) - tempRepo(t) - elsewhere := t.TempDir() - path := writeConfig(t, elsewhere, `{"project": 7}`) - - // When - got := configJSON(t, "--config-path", path) - - // Then - if v := got["configPath"]; v["value"] != path || v["source"] != "cli" { - t.Errorf("configPath = %v", v) - } - if v := got["project"]; v["value"] != float64(7) { - t.Errorf("project = %v", v) - } - }) - - t.Run("human output shows values and sources", func(t *testing.T) { - // Given - isolateStorage(t) - root := tempRepo(t) - writeConfig(t, root, `{"project": 12345}`) - - // When - out, err := run("", "config") - - // Then - if err != nil { - t.Fatal(err) - } - for _, want := range []string{"12345", "config", "https://api.flagsmith.com", "default", "flagsmith.json"} { - if !strings.Contains(out, want) { - t.Errorf("output = %q, want it to contain %q", out, want) - } - } - }) - - t.Run("unknown config fields warn on stderr", func(t *testing.T) { - // Given - isolateStorage(t) - root := tempRepo(t) - writeConfig(t, root, `{"project": 1, "enviroment": "typo"}`) - - // When - out, err := run("", "config") - - // Then - if err != nil { - t.Fatal(err) - } - if !strings.Contains(out, "unknown field") { - t.Errorf("output = %q, want an unknown-field warning", out) - } - }) - - t.Run("non-numeric FLAGSMITH_PROJECT is taken as a name", func(t *testing.T) { - // Given - isolateStorage(t) - tempRepo(t) - t.Setenv("FLAGSMITH_PROJECT", "my-app") - - // When - got := configJSON(t) - - // Then - if v := got["project"]; v["value"] != "my-app" || v["source"] != "env" { - t.Errorf("project = %v, want the name carried through from the env var", v) - } - }) - - t.Run("server-side key via -e is rejected", func(t *testing.T) { - // Given - isolateStorage(t) - tempRepo(t) - - // When / Then - if _, err := run("", "config", "-e", "ser.AbCd"); err == nil || - !strings.Contains(hintFor(err), "FLAGSMITH_ENVIRONMENT_KEY") { - t.Errorf("err = %v (hint %q), want a hint pointing at FLAGSMITH_ENVIRONMENT_KEY", err, hintFor(err)) - } - }) -} - -// fakeTTY makes prompts believe stdin is a terminal for one test. -func fakeTTY(t *testing.T) { - t.Helper() - orig := stdinIsTTY - stdinIsTTY = func() bool { return true } - t.Cleanup(func() { stdinIsTTY = orig }) -} - -func loadWritten(t *testing.T, dir string) *config.File { - t.Helper() - f, _, err := config.Load(filepath.Join(dir, "flagsmith.json")) - if err != nil { - t.Fatal(err) - } - return f -} - -// refID returns a config reference's ID, or 0 when unset — nil-safe for terse -// test assertions. -func refID(r *config.Ref) int { - if r == nil { - return 0 - } - return r.ID -} - -func TestInitNonInteractive(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - root := tempRepo(t) - setMasterKey(t, f.srv.URL) - - // When - out, err := run("", "init", "--api-url", f.srv.URL, - "--project", "12345", "--environment", "WqXhZk8sVY3dGgTqZ9pJmN", "--yes") - - // Then - if err != nil { - t.Fatalf("init: %v\noutput: %s", err, out) - } - for _, want := range []string{"Verified access", "Wrote flagsmith.json"} { - if !strings.Contains(out, want) { - t.Errorf("output = %q, want it to contain %q", out, want) - } - } - written := loadWritten(t, root) - if refID(written.Project) != 12345 || written.Environment != "WqXhZk8sVY3dGgTqZ9pJmN" { - t.Errorf("written = %+v", written) - } - if written.APIURL != f.srv.URL { - t.Errorf("apiUrl = %q, want the non-SaaS instance recorded", written.APIURL) - } - if !strings.Contains(written.Schema, "schema/flagsmith.json") { - t.Errorf("$schema = %q", written.Schema) - } - if got := cache.Load(f.srv.URL); got.Environments["WqXhZk8sVY3dGgTqZ9pJmN"] != "Development" { - t.Errorf("cache = %+v, want environment names seeded", got) - } -} - -func TestInitNonInteractiveRequiresProject(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - tempRepo(t) - setMasterKey(t, f.srv.URL) - - // When - _, err := run("", "init", "--api-url", f.srv.URL, "--yes") - - // Then - var ue *usageError - if !errors.As(err, &ue) { - t.Fatalf("err = %v, want a usage error (exit 2)", err) - } - if !strings.Contains(err.Error(), "--project") { - t.Errorf("err = %v, want it to name --project", err) - } -} - -// An empty ref — e.g. --project "$PROJECT_ID" with the variable unset in CI — -// is absent input, not a decision: exit 2 or fall through, never a panic. -func TestInitEmptyRefExitsCleanly(t *testing.T) { - t.Run("--project empty exits 2 non-interactively", func(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - tempRepo(t) - setMasterKey(t, f.srv.URL) - - // When - _, err := run("", "init", "--api-url", f.srv.URL, "--project", "", "--yes") - - // Then - var ue *usageError - if !errors.As(err, &ue) { - t.Fatalf("err = %v, want a usage error (exit 2)", err) - } - }) - - t.Run("--organisation empty falls back to the lone organisation", func(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - tempRepo(t) - setMasterKey(t, f.srv.URL) - - // When - out, err := run("", "init", "--organisation", "", "--api-url", f.srv.URL, "--create-project", "smoke", "--yes") - - // Then - if err != nil { - t.Fatalf("init --create-project: %v\noutput: %s", err, out) - } - if !strings.Contains(out, "Created project smoke") { - t.Errorf("output = %q, want the project created in the lone organisation", out) - } - }) -} - -// --version reports the resolved build version. -func TestVersionFlag(t *testing.T) { - // When - out, err := run("", "--version") - - // Then - if err != nil { - t.Fatalf("--version: %v\noutput: %s", err, out) - } - if !strings.Contains(out, version.Version) { - t.Errorf("output = %q, want it to carry %q", out, version.Version) - } -} - -func TestSchemaURL(t *testing.T) { - orig := version.Version - t.Cleanup(func() { version.Version = orig }) - - // Given - version.Version = "v1.2.3" - if got := schemaURL(); !strings.Contains(got, "/v1.2.3/schema/flagsmith.json") { - t.Errorf("schemaURL() = %q, want pinned to the release tag", got) - } - - // Given - version.Version = "dev (2f71d6f)" - if got := schemaURL(); !strings.Contains(got, "/main/schema/flagsmith.json") { - t.Errorf("schemaURL() = %q, want main for a non-release build", got) - } -} - -func TestInitNoCredentials(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - tempRepo(t) - - // When - _, err := run("", "init", "--api-url", f.srv.URL, "--project", "12345", "--yes") - - // Then - if err == nil { - t.Fatal("expected an error with no credentials") - } - for _, want := range []string{"FLAGSMITH_API_KEY", "flagsmith login"} { - if !strings.Contains(hintFor(err), want) { - t.Errorf("err = %v (hint %q), want the hint to mention %q", err, hintFor(err), want) - } - } -} - -func TestInitRefusesOverwriteWithoutYes(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - root := tempRepo(t) - writeConfig(t, root, `{"project": 1}`) - setMasterKey(t, f.srv.URL) - - // When - _, err := run("", "init", "--api-url", f.srv.URL, "--project", "12345") - - // Then - var ue *usageError - if !errors.As(err, &ue) || !strings.Contains(err.Error(), "--yes") { - t.Errorf("err = %v, want a usage error (exit 2) naming --yes", err) - } -} - -func TestInitCreateProjectFlag(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - root := tempRepo(t) - setMasterKey(t, f.srv.URL) - - // When - out, err := run("", "init", "--api-url", f.srv.URL, - "--create-project", "acme-new", "--create-environment", "Development", "--yes") - - // Then - if err != nil { - t.Fatalf("init: %v\noutput: %s", err, out) - } - f.mu.Lock() - created, createdEnvs := append([]string{}, f.created...), append([]string{}, f.createdEnvs...) - f.mu.Unlock() - if len(created) != 1 || created[0] != "acme-new" { - t.Errorf("created projects = %v", created) - } - if len(createdEnvs) != 1 || createdEnvs[0] != "Development" { - t.Errorf("created envs = %v", createdEnvs) - } - if w := loadWritten(t, root); refID(w.Project) != 999 || w.Environment != "createdEnvKey00000000" { - t.Errorf("written = %+v", w) - } -} - -func TestInitCreateProjectRequiresOrgWhenMultiOrg(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - f.orgs = []map[string]any{{"id": 3, "name": "Acme"}, {"id": 7, "name": "Beta"}} - tempRepo(t) - setMasterKey(t, f.srv.URL) - - // When - _, err := run("", "init", "--api-url", f.srv.URL, "--create-project", "x", "--yes") - - // Then - var ue *usageError - if !errors.As(err, &ue) || !strings.Contains(err.Error(), "organisation") { - t.Errorf("err = %v, want a usage error naming --organisation", err) - } -} - -func TestInitCreateProjectConflictsWithProject(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - tempRepo(t) - setMasterKey(t, f.srv.URL) - - // When / Then - _, err := run("", "init", "--api-url", f.srv.URL, "--project", "101", "--create-project", "x", "--yes") - var ue *usageError - if !errors.As(err, &ue) || !strings.Contains(err.Error(), "mutually exclusive") { - t.Errorf("err = %v, want a mutual-exclusion usage error", err) - } -} - -func TestInitCreateEnvironmentFlag(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - root := tempRepo(t) - setMasterKey(t, f.srv.URL) - - // When - out, err := run("", "init", "--api-url", f.srv.URL, - "--project", "101", "--create-environment", "Staging", "--yes") - - // Then - if err != nil { - t.Fatalf("init: %v\noutput: %s", err, out) - } - f.mu.Lock() - createdEnvs := append([]string{}, f.createdEnvs...) - f.mu.Unlock() - if len(createdEnvs) != 1 || createdEnvs[0] != "Staging" { - t.Errorf("created envs = %v", createdEnvs) - } - if w := loadWritten(t, root); w.Environment != "createdEnvKey00000000" { - t.Errorf("written = %+v", w) - } -} - -func TestInitCreateEnvironmentConflictsWithEnvironment(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - tempRepo(t) - setMasterKey(t, f.srv.URL) - - // When / Then - _, err := run("", "init", "--api-url", f.srv.URL, - "--project", "101", "--environment", "WqXhZk8sVY3dGgTqZ9pJmN", "--create-environment", "x", "--yes") - var ue *usageError - if !errors.As(err, &ue) || !strings.Contains(err.Error(), "mutually exclusive") { - t.Errorf("err = %v, want a mutual-exclusion usage error", err) - } -} - -func TestPromptSelfGuardsWithoutTTY(t *testing.T) { - // Given - orig := stdinIsTTY - stdinIsTTY = func() bool { return false } - t.Cleanup(func() { stdinIsTTY = orig }) - yesFlag = false - initPrompts(rootCmd) - - // When / Then - if _, err := selectPrompt(rootCmd, "project", "Project", []string{"a"}, 0); err == nil { - t.Error("selectPrompt should refuse without a TTY") - } else { - var ue *usageError - if !errors.As(err, &ue) || !strings.Contains(err.Error(), "--project") { - t.Errorf("err = %v, want usage error naming --project", err) - } - } -} - -func TestInteractivePromptsGoToStderr(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - f.orgs = []map[string]any{{"id": 3, "name": "Acme"}, {"id": 7, "name": "Beta"}} - f.projects["7"] = []map[string]any{{"id": 202, "name": "beta-app"}} - f.envs["202"] = []map[string]any{ - {"id": 9, "name": "Development", "api_key": "BetaDevKey00000000000"}, - } - tempRepo(t) - setMasterKey(t, f.srv.URL) - fakeTTY(t) - - // When - stdout, stderr, err := runSplit("2\n1\n1\n", "init", "--api-url", f.srv.URL) - - // Then - if err != nil { - t.Fatalf("init: %v\nstderr: %s", err, stderr) - } - if stdout != "" { - t.Errorf("stdout = %q, want empty — prompt UI must not leak into the data stream", stdout) - } - for _, label := range []string{"Organisation", "Project", "Default environment"} { - if !strings.Contains(stderr, label) { - t.Errorf("stderr = %q, want prompt label %q", stderr, label) - } - } -} - -func TestInitInteractiveMultiOrg(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - f.orgs = []map[string]any{{"id": 3, "name": "Acme"}, {"id": 7, "name": "Beta"}} - f.projects["7"] = []map[string]any{{"id": 202, "name": "beta-app"}} - f.envs["202"] = []map[string]any{ - {"id": 9, "name": "Development", "api_key": "BetaDevKey00000000000"}, - } - root := tempRepo(t) - setMasterKey(t, f.srv.URL) - fakeTTY(t) - - // When - out, err := run("2\n1\n1\n", "init", "--api-url", f.srv.URL) - - // Then - if err != nil { - t.Fatalf("init: %v\noutput: %s", err, out) - } - for _, want := range []string{"Organisation", "Project", "environment", "Wrote flagsmith.json"} { - if !strings.Contains(out, want) { - t.Errorf("output = %q, want it to contain %q", out, want) - } - } - written := loadWritten(t, root) - if refID(written.Project) != 202 || written.Environment != "BetaDevKey00000000000" { - t.Errorf("written = %+v", written) - } - if refID(written.Organisation) != 7 { - t.Errorf("organisation = %d, want 7 recorded for a multi-org user", refID(written.Organisation)) - } -} - -func TestInitInteractiveCreateProject(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - root := tempRepo(t) - setMasterKey(t, f.srv.URL) - fakeTTY(t) - - // When - out, err := run("2\n\n\n", "init", "--api-url", f.srv.URL) - - // Then - if err != nil { - t.Fatalf("init: %v\noutput: %s", err, out) - } - if !strings.Contains(out, "Created project") { - t.Errorf("output = %q, want a created-project line", out) - } - f.mu.Lock() - created := append([]string{}, f.created...) - createdEnvs := append([]string{}, f.createdEnvs...) - f.mu.Unlock() - if len(created) != 1 || created[0] != filepath.Base(root) { - t.Errorf("created = %v, want the cwd name as the default project name", created) - } - if len(createdEnvs) != 1 || createdEnvs[0] != "Development" { - t.Errorf("createdEnvs = %v, want a Development environment created", createdEnvs) - } - if written := loadWritten(t, root); refID(written.Project) != 999 || written.Environment != "createdEnvKey00000000" { - t.Errorf("written = %+v", written) - } -} - -func TestInitEmptyProjectPromptsEnvironmentCreation(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - f.envs["101"] = []map[string]any{} - root := tempRepo(t) - setMasterKey(t, f.srv.URL) - fakeTTY(t) - - // When - out, err := run("1\n\n", "init", "--api-url", f.srv.URL) - - // Then - if err != nil { - t.Fatalf("init: %v\noutput: %s", err, out) - } - if !strings.Contains(out, "environment") { - t.Errorf("output = %q, want an environment-creation prompt", out) - } - f.mu.Lock() - createdEnvs := append([]string{}, f.createdEnvs...) - f.mu.Unlock() - if len(createdEnvs) != 1 || createdEnvs[0] != "Development" { - t.Errorf("createdEnvs = %v, want a Development environment created", createdEnvs) - } - if written := loadWritten(t, root); written.Environment != "createdEnvKey00000000" { - t.Errorf("written = %+v", written) - } -} - -func TestInitEmptyProjectNonInteractiveSkipsEnvironment(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - f.envs["101"] = []map[string]any{} - root := tempRepo(t) - setMasterKey(t, f.srv.URL) - - // When - out, err := run("", "init", "--api-url", f.srv.URL, "--project", "101", "--yes") - - // Then - if err != nil { - t.Fatalf("init: %v\noutput: %s", err, out) - } - f.mu.Lock() - createdEnvs := append([]string{}, f.createdEnvs...) - f.mu.Unlock() - if len(createdEnvs) != 0 { - t.Errorf("createdEnvs = %v, want none created without a TTY", createdEnvs) - } - if written := loadWritten(t, root); written.Environment != "" { - t.Errorf("written = %+v, want no environment", written) - } -} - -func TestInitPreservesExistingOrganisation(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - root := tempRepo(t) - writeConfig(t, root, `{"project": 12345, "organisation": 3, "environment": "WqXhZk8sVY3dGgTqZ9pJmN"}`) - setMasterKey(t, f.srv.URL) - - // When - out, err := run("", "init", "--api-url", f.srv.URL, "--project", "12345", "--yes") - - // Then - if err != nil { - t.Fatalf("init: %v\noutput: %s", err, out) - } - if written := loadWritten(t, root); refID(written.Organisation) != 3 { - t.Errorf("organisation = %d, want 3 preserved", refID(written.Organisation)) - } -} - -func TestInitPreservesSDKAPIURL(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - root := tempRepo(t) - writeConfig(t, root, `{"project": 12345, "environment": "WqXhZk8sVY3dGgTqZ9pJmN", "sdkApiUrl": "https://sdk.acme.internal"}`) - setMasterKey(t, f.srv.URL) - - // When - out, err := run("", "init", "--api-url", f.srv.URL, "--project", "12345", "--yes") - - // Then - if err != nil { - t.Fatalf("init: %v\noutput: %s", err, out) - } - if written := loadWritten(t, root); written.SDKAPIURL != "https://sdk.acme.internal" { - t.Errorf("sdkApiUrl = %q, want it preserved", written.SDKAPIURL) - } -} - -func TestInitRefusesToOverwriteMalformedFile(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - root := tempRepo(t) - const malformed = "{ this is not valid json" - writeConfig(t, root, malformed) - valid := filepath.Join(t.TempDir(), "valid.json") - if err := os.WriteFile(valid, []byte(`{"project": 12345}`), 0o644); err != nil { - t.Fatal(err) - } - setMasterKey(t, f.srv.URL) - - // When - _, err := run("", "init", "--api-url", f.srv.URL, "--config-path", valid, "--project", "12345", "--yes") - - // Then - if err == nil { - t.Fatal("expected init to refuse to overwrite a malformed file") - } - got, readErr := os.ReadFile(filepath.Join(root, "flagsmith.json")) - if readErr != nil { - t.Fatal(readErr) - } - if string(got) != malformed { - t.Errorf("file was modified: %q, want it left untouched", got) - } -} - -func TestInitReinitReoffersOrgPicker(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - f.orgs = []map[string]any{{"id": 7, "name": "Beta"}, {"id": 3, "name": "Acme"}} - f.projects["7"] = []map[string]any{{"id": 202, "name": "beta-app"}} - f.envs["202"] = []map[string]any{{"id": 9, "name": "Development", "api_key": "BetaDevKey00000000000"}} - root := tempRepo(t) - writeConfig(t, root, `{"project": 101, "organisation": 3, "environment": "WqXhZk8sVY3dGgTqZ9pJmN"}`) - setMasterKey(t, f.srv.URL) - fakeTTY(t) - - // When - out, err := run("\n1\n1\n", "init", "--api-url", f.srv.URL) - - // Then - if err != nil { - t.Fatalf("init: %v\noutput: %s", err, out) - } - if !strings.Contains(out, "Organisation") { - t.Errorf("output = %q, want the org picker re-offered on re-init", out) - } - if written := loadWritten(t, root); refID(written.Organisation) != 3 { - t.Errorf("organisation = %d, want the pre-selected current org (3)", refID(written.Organisation)) - } -} - -func TestInitInvalidChoiceReprompts(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - root := tempRepo(t) - setMasterKey(t, f.srv.URL) - fakeTTY(t) - - // When - out, err := run("99\n1\n1\n", "init", "--api-url", f.srv.URL) - - // Then - if err != nil { - t.Fatalf("init: %v\noutput: %s", err, out) - } - if !strings.Contains(out, "between 1 and") { - t.Errorf("output = %q, want a re-prompt instead of a crash", out) - } - if written := loadWritten(t, root); refID(written.Project) != 101 { - t.Errorf("written = %+v", written) - } -} - -func TestInitReinitShowsDiffAndConfirms(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - root := tempRepo(t) - writeConfig(t, root, `{"project": 12345, "environment": "WqXhZk8sVY3dGgTqZ9pJmN"}`) - setMasterKey(t, f.srv.URL) - fakeTTY(t) - - // When - out, err := run("1\n2\ny\n", "init", "--api-url", f.srv.URL) - - // Then - if err != nil { - t.Fatalf("init: %v\noutput: %s", err, out) - } - for _, want := range []string{`- "project": 12345`, `+ "project": 101`, "Write changes?"} { - if !strings.Contains(out, want) { - t.Errorf("output = %q, want it to contain %q", out, want) - } - } - if written := loadWritten(t, root); refID(written.Project) != 101 || written.Environment != "K2mVsGdXhZ8kQqZ9pJmNbJ" { - t.Errorf("written = %+v", written) - } -} - -func TestBareInvocationNudgesInit(t *testing.T) { - // Given - isolateStorage(t) - tempRepo(t) - - // When - out, err := run("") - - // Then - if err != nil { - t.Fatal(err) - } - if !strings.Contains(out, "flagsmith init") { - t.Errorf("output = %q, want a nudge towards flagsmith init", out) - } -} - -// runSplit is run with stdout and stderr captured separately. -func runSplit(stdin string, args ...string) (string, string, error) { - resetFlags() - if args == nil { - args = []string{} - } - outBuf, errBuf := &bytes.Buffer{}, &bytes.Buffer{} - rootCmd.SetOut(outBuf) - rootCmd.SetErr(errBuf) - rootCmd.SetIn(strings.NewReader(stdin)) - rootCmd.SetArgs(args) - err := rootCmd.Execute() - return outBuf.String(), errBuf.String(), err -} - -func TestJSONIsGlobal(t *testing.T) { - t.Run("auth status --json", func(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - setMasterKey(t, f.srv.URL) - - // When - out, err := run("", "auth", "status", "--api-url", f.srv.URL, "--json") - - // Then - if err != nil { - t.Fatalf("auth status --json: %v", err) - } - var status struct { - APIURL string `json:"apiUrl"` - Kind string `json:"kind"` - Organisations []struct { - ID int `json:"id"` - Name string `json:"name"` - } `json:"organisations"` - Source string `json:"credentialSource"` - } - if err := json.Unmarshal([]byte(out), &status); err != nil { - t.Fatalf("parsing %q: %v", out, err) - } - // The source names the exact variable, host scope included. - if status.Kind != "master" || status.Source != "$"+scopedEnvName(envAPIKey, f.srv.URL) || - len(status.Organisations) != 1 || status.Organisations[0].Name != "Acme" { - t.Errorf("status = %+v", status) - } - }) - - t.Run("auth token --json", func(t *testing.T) { - // Given - isolateStorage(t) - newFakeInstance(t) - t.Setenv(envAPIKey, masterKey) - - // When - out, err := run("", "auth", "token", "--json") - - // Then - if err != nil { - t.Fatalf("auth token --json: %v", err) - } - var token struct { - Token string `json:"token"` - } - if err := json.Unmarshal([]byte(out), &token); err != nil { - t.Fatalf("parsing %q: %v", out, err) - } - if token.Token != masterKey { - t.Errorf("token = %q", token.Token) - } - }) - - t.Run("FLAGSMITH_JSON_OUTPUT enables JSON", func(t *testing.T) { - // Given - isolateStorage(t) - tempRepo(t) - t.Setenv("FLAGSMITH_JSON_OUTPUT", "1") - - // When - out, err := run("", "config") - - // Then - if err != nil { - t.Fatal(err) - } - if !json.Valid([]byte(out)) { - t.Errorf("output = %q, want JSON via env var", out) - } - }) -} - -func TestWarningsGoToStderr(t *testing.T) { - // Given - isolateStorage(t) - root := tempRepo(t) - writeConfig(t, root, `{"project": 1, "enviroment": "typo"}`) - - // When - stdout, stderr, err := runSplit("", "config") - - // Then - if err != nil { - t.Fatal(err) - } - if strings.Contains(stdout, "unknown field") { - t.Errorf("stdout = %q — warnings belong on stderr", stdout) - } - if !strings.Contains(stderr, "unknown field") { - t.Errorf("stderr = %q, want the unknown-field warning", stderr) - } -} - -func TestLogoutRevokeWarningGoesToStderr(t *testing.T) { - // Given - isolateStorage(t) - if err := auth.Save(&auth.Credentials{ - Kind: auth.KindOAuth, APIURL: "http://127.0.0.1:1", - AccessToken: "x", RefreshToken: "y", - ExpiresAt: time.Now().Add(10 * time.Minute), - }); err != nil { - t.Fatal(err) - } - - // When - stdout, stderr, err := runSplit("", "logout", "--api-url", "http://127.0.0.1:1") - - // Then - if err != nil { - t.Fatalf("logout: %v", err) - } - if stdout != "" { - t.Errorf("stdout = %q, want empty", stdout) - } - if !strings.Contains(stderr, "Logged out") { - t.Errorf("stderr = %q, want the confirmation", stderr) - } - if !strings.Contains(stderr, "Warning") { - t.Errorf("stderr = %q, want the revoke warning", stderr) - } -} - -func TestBrowserLoginWithoutTTYNeverOpensBrowser(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - - // When - resetFlags() - out := &syncBuffer{} - rootCmd.SetOut(out) - rootCmd.SetErr(out) - rootCmd.SetIn(strings.NewReader("")) - rootCmd.SetArgs([]string{"login", "--api-url", f.srv.URL}) - done := make(chan error, 1) - go func() { done <- rootCmd.Execute() }() - - authURLPattern := regexp.MustCompile(`https?://\S+/oauth/authorize/\?\S+`) - var q url.Values - deadline := time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) && q == nil { - if m := authURLPattern.FindString(out.String()); m != "" { - u, err := url.Parse(m) - if err != nil { - t.Fatal(err) - } - q = u.Query() - break - } - time.Sleep(10 * time.Millisecond) - } - if q == nil { - t.Fatalf("no authorization URL printed; output: %q", out.String()) - } - if _, err := http.Get(q.Get("redirect_uri") + "?code=c&state=" + url.QueryEscape(q.Get("state"))); err != nil { - t.Fatal(err) - } - - // Then - select { - case err := <-done: - if err != nil { - t.Fatalf("login: %v\noutput: %s", err, out.String()) - } - case <-time.After(10 * time.Second): - t.Fatal("login did not return") - } -} - -func TestBrowserLoginRefusesNoInput(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - - // When - _, err := run("", "login", "--api-url", f.srv.URL, "--no-input") - - // Then - if err == nil || !strings.Contains(hintFor(err), "FLAGSMITH_API_KEY") { - t.Errorf("err = %v (hint %q), want a refusal hinting at FLAGSMITH_API_KEY", err, hintFor(err)) - } -} - -// --yes is authorization, not a liveness switch, so it must NOT block a -// browser login the way --no-input does: with --yes the login proceeds to the -// OAuth callback flow rather than refusing up front. -func TestBrowserLoginNotBlockedByYes(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - - // When - resetFlags() - out := &syncBuffer{} - rootCmd.SetOut(out) - rootCmd.SetErr(out) - rootCmd.SetIn(strings.NewReader("")) - rootCmd.SetArgs([]string{"login", "--api-url", f.srv.URL, "--yes"}) - done := make(chan error, 1) - go func() { done <- rootCmd.Execute() }() - - authURLPattern := regexp.MustCompile(`https?://\S+/oauth/authorize/\?\S+`) - var q url.Values - deadline := time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) && q == nil { - if m := authURLPattern.FindString(out.String()); m != "" { - u, err := url.Parse(m) - if err != nil { - t.Fatal(err) - } - q = u.Query() - break - } - time.Sleep(10 * time.Millisecond) - } - if q == nil { - t.Fatalf("--yes must not refuse login; no authorization URL printed; output: %q", out.String()) - } - if _, err := http.Get(q.Get("redirect_uri") + "?code=c&state=" + url.QueryEscape(q.Get("state"))); err != nil { - t.Fatal(err) - } - - // Then - select { - case err := <-done: - if err != nil { - t.Fatalf("login: %v\noutput: %s", err, out.String()) - } - case <-time.After(10 * time.Second): - t.Fatal("login did not return") - } -} - -func TestResolveCredentialRefreshesOnceUnderConcurrency(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - if err := auth.Save(&auth.Credentials{ - APIURL: f.srv.URL, - AccessToken: "stale", - RefreshToken: "cmd-refresh", - ExpiresAt: time.Now().Add(-time.Hour), - }); err != nil { - t.Fatal(err) - } - apiURL = f.srv.URL - resetCredentialCache() - - // When - const n = 20 - var wg sync.WaitGroup - results := make([]*activeCredential, n) - errs := make([]error, n) - start := make(chan struct{}) - for i := range n { - wg.Add(1) - go func(i int) { - defer wg.Done() - <-start - results[i], errs[i] = resolveCredential(context.Background()) - }(i) - } - close(start) - wg.Wait() - - // Then - if got := f.refreshCount(); got != 1 { - t.Errorf("refresh POSTs = %d, want exactly 1 (herd collapsed)", got) - } - for i := range n { - if errs[i] != nil { - t.Fatalf("caller %d: %v", i, errs[i]) - } - if results[i].token != oauthAccess { - t.Errorf("caller %d token = %q, want the refreshed access token", i, results[i].token) - } - } -} - -func TestProjectNameResolvesForEnvironmentLookup(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - root := tempRepo(t) - writeConfig(t, root, `{"project": "acme-api", "environment": "Development", "apiUrl": "`+f.srv.URL+`"}`) - setMasterKey(t, f.srv.URL) - - // When - out, err := run("", "flag", "list") - - // Then - if err != nil { - t.Fatalf("flag list: %v\noutput: %s", err, out) - } - if got := f.featuresEnv(); got != "1" { - t.Errorf("features environment = %q, want env id resolved via the named project", got) - } -} - -func TestUnknownProjectNameErrors(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - root := tempRepo(t) - writeConfig(t, root, `{"project": "ghost", "environment": "Development", "apiUrl": "`+f.srv.URL+`"}`) - setMasterKey(t, f.srv.URL) - - // When / Then - _, err := run("", "flag", "list") - if err == nil || !strings.Contains(err.Error(), "ghost") { - t.Errorf("err = %v, want a not-found error naming the project", err) - } -} - -func TestFlagsList(t *testing.T) { - t.Run("human table with count", func(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - root := tempRepo(t) - writeConfig(t, root, `{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN", "apiUrl": "`+f.srv.URL+`"}`) - setMasterKey(t, f.srv.URL) - - // When - out, err := run("", "flag", "list") - - // Then - if err != nil { - t.Fatalf("flags list: %v\noutput: %s", err, out) - } - for _, want := range []string{ - "NAME", "TYPE", "STATE", "VALUE", "LIFECYCLE", - "onboarding_banner", "standard", "on", "live", - "max_items", "25", "2 flags", - } { - if !strings.Contains(out, want) { - t.Errorf("output = %q, want it to contain %q", out, want) - } - } - }) - - t.Run("json is a curated array with state hoisted", func(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - root := tempRepo(t) - writeConfig(t, root, `{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN", "apiUrl": "`+f.srv.URL+`"}`) - setMasterKey(t, f.srv.URL) - - // When - out, err := run("", "flag", "list", "--json") - - // Then - if err != nil { - t.Fatal(err) - } - var flags []map[string]any - if err := json.Unmarshal([]byte(out), &flags); err != nil { - t.Fatalf("parsing %q: %v", out, err) - } - if len(flags) != 2 { - t.Fatalf("flags = %+v", flags) - } - if flags[0]["feature"] != "onboarding_banner" || flags[0]["enabled"] != true { - t.Errorf("item = %+v, want curated fields at top level", flags[0]) - } - if _, ok := flags[0]["environment_feature_state"]; ok { - t.Errorf("item = %+v, want the raw nested state dropped", flags[0]) - } - if _, ok := flags[0]["lifecycle_stage"]; !ok { - t.Errorf("item = %+v, want lifecycle_stage present", flags[0]) - } - }) - - t.Run("empty", func(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - f.features["101"] = []map[string]any{} - root := tempRepo(t) - writeConfig(t, root, `{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN", "apiUrl": "`+f.srv.URL+`"}`) - setMasterKey(t, f.srv.URL) - - // When - out, err := run("", "flag", "list") - - // Then - if err != nil { - t.Fatal(err) - } - if !strings.Contains(out, "No flags") { - t.Errorf("output = %q, want a no-flags message", out) - } - }) - - t.Run("environment from FLAGSMITH_ENVIRONMENT_KEY fallback", func(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - root := tempRepo(t) - writeConfig(t, root, `{"project": 101, "apiUrl": "`+f.srv.URL+`"}`) - setMasterKey(t, f.srv.URL) - setEnvCred(t, envEnvironmentKey, f.srv.URL, "WqXhZk8sVY3dGgTqZ9pJmN") - - // When - out, err := run("", "flag", "list") - - // Then - if err != nil { - t.Fatalf("flags list: %v\noutput: %s", err, out) - } - if !strings.Contains(out, "onboarding_banner") { - t.Errorf("output = %q", out) - } - }) - - t.Run("no environment errors", func(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - root := tempRepo(t) - writeConfig(t, root, `{"project": 101, "apiUrl": "`+f.srv.URL+`"}`) - setMasterKey(t, f.srv.URL) - - // When - _, err := run("", "flag", "list") - - // Then - if err == nil || !strings.Contains(err.Error(), "environment") { - t.Errorf("err = %v, want a missing-environment error", err) - } - if hint := hintFor(err); !strings.Contains(hint, "-e") || !strings.Contains(hint, "flagsmith init") { - t.Errorf("hint = %q, want it to offer -e and `flagsmith init`", hint) - } - }) - - t.Run("off state and a truncated long value", func(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - long := strings.Repeat("x", 200) - f.features["101"] = []map[string]any{{ - "id": 1, "name": "blob", "type": "MULTIVARIATE", - "environment_feature_state": map[string]any{"enabled": false, "feature_state_value": long}, - }} - root := tempRepo(t) - writeConfig(t, root, `{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN", "apiUrl": "`+f.srv.URL+`"}`) - setMasterKey(t, f.srv.URL) - - // When - out, err := run("", "flag", "list") - - // Then - if err != nil { - t.Fatalf("flags list: %v", err) - } - if !strings.Contains(out, "off") || !strings.Contains(out, "multivariate") || !strings.Contains(out, "…") { - t.Errorf("output = %q, want off/multivariate/truncation", out) - } - if strings.Contains(out, long) { - t.Errorf("output = %q, want the long value truncated", out) - } - }) - - t.Run("multi-line JSON value stays on one row", func(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - f.features["101"] = []map[string]any{{ - "id": 1, "name": "blob", "type": "STANDARD", - "environment_feature_state": map[string]any{ - "enabled": true, "feature_state_value": "[\n {\n \"value\": \"EQUAL\"\n }\n]", - }, - }} - root := tempRepo(t) - writeConfig(t, root, `{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN", "apiUrl": "`+f.srv.URL+`"}`) - setMasterKey(t, f.srv.URL) - - // When - out, err := run("", "flag", "list") - - // Then - if err != nil { - t.Fatalf("flags list: %v", err) - } - lines := strings.Split(strings.TrimRight(out, "\n"), "\n") - for _, l := range lines { - // Any JSON fragment must sit on the blob row, not spill onto its own. - if strings.Contains(l, `"value"`) && !strings.HasPrefix(l, "blob") { - t.Errorf("value spilled onto its own row: %q", l) - } - } - if !strings.Contains(out, `[ { "value": "EQUAL" } ]`) { - t.Errorf("output = %q, want the value flattened to one line", out) - } - }) -} - -// The per-feature feature-segments reads behind flag list --segment fan out -// concurrently: the endpoint requires a feature filter, so one read per -// overridden feature is unavoidable, but the wall clock should pay for one. -func TestFlagListSegmentFansOut(t *testing.T) { - // Given - f := flagUpdateEnv(t) - override := map[string]any{"enabled": true, "feature_state_value": "x"} - f.features["101"] = []map[string]any{ - {"id": 1, "name": "alpha", "type": "STANDARD", "segment_feature_state": override, - "environment_feature_state": map[string]any{"enabled": true, "feature_state_value": nil}}, - {"id": 2, "name": "bravo", "type": "STANDARD", "segment_feature_state": override, - "environment_feature_state": map[string]any{"enabled": true, "feature_state_value": nil}}, - {"id": 3, "name": "charlie", "type": "STANDARD", "segment_feature_state": override, - "environment_feature_state": map[string]any{"enabled": true, "feature_state_value": nil}}, - } - for id := 1; id <= 3; id++ { - withFeatureSegments(f, id, map[string]any{ - "id": 1200 + id, "segment": 12, "segment_name": "powerusers", "priority": id - 1, "environment": 1, - }) - } - withFeatureSegmentDelay(f, 30*time.Millisecond) - - // When - out, err := run("", "flag", "list", "--segment", "12") - - // Then - if err != nil { - t.Fatalf("flag list --segment: %v\noutput: %s", err, out) - } - alpha, bravo, charlie := strings.Index(out, "alpha"), strings.Index(out, "bravo"), strings.Index(out, "charlie") - if alpha == -1 || bravo == -1 || charlie == -1 || alpha > bravo || bravo > charlie { - t.Errorf("output = %q, want alpha, bravo, charlie in order", out) - } - if got := f.featureSegmentsCalls(); got != 3 { - t.Errorf("feature-segments calls = %d, want 3", got) - } - if got := f.featureSegmentsPeak(); got < 2 { - t.Errorf("feature-segments peak concurrency = %d, want the reads fanned out (>= 2)", got) - } -} - -func TestFlagListSegment(t *testing.T) { - t.Run("lists only the flags overridden for the segment", func(t *testing.T) { - f := flagUpdateEnv(t) - withSegmentOverride(f, true) // max_items with a segment override - - out, err := run("", "flag", "list", "--segment", "12") - if err != nil { - t.Fatalf("flag list --segment: %v\noutput: %s", err, out) - } - if f.featuresSeg() != "12" { - t.Errorf("features segment = %q, want 12", f.featuresSeg()) - } - for _, want := range []string{"NAME", "TYPE", "STATE", "VALUE", "max_items", "special", "on"} { - if !strings.Contains(out, want) { - t.Errorf("output = %q, want %q", out, want) - } - } - if strings.Contains(out, "LIFECYCLE") { - t.Errorf("output = %q, segment list should drop LIFECYCLE", out) - } - }) - - t.Run("--json is the segment-override shape", func(t *testing.T) { - f := flagUpdateEnv(t) - withSegmentOverride(f, true) - withFeatureSegments(f, 2, map[string]any{ - "id": 1200, "segment": 12, "segment_name": "powerusers", "priority": 1, "environment": 1, - }) - - out, err := run("", "flag", "list", "--segment", "12", "--json") - if err != nil { - t.Fatal(err) - } - var arr []map[string]any - if err := json.Unmarshal([]byte(out), &arr); err != nil { - t.Fatalf("parsing %q: %v", out, err) - } - if len(arr) != 1 || arr[0]["feature"] != "max_items" || arr[0]["enabled"] != true { - t.Errorf("items = %+v", arr) - } - seg, _ := arr[0]["segment"].(map[string]any) - if seg == nil || seg["id"] != float64(12) || seg["name"] != "powerusers" { - t.Errorf("segment = %+v, want an {id, name} object", arr[0]["segment"]) - } - if arr[0]["priority"] != float64(1) { - t.Errorf("priority = %v, want 1", arr[0]["priority"]) - } - }) - - t.Run("no overrides for the segment", func(t *testing.T) { - f := flagUpdateEnv(t) - withSegmentOverride(f, false) // max_items, no segment override - - out, err := run("", "flag", "list", "--segment", "99") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(out, "No segment overrides") { - t.Errorf("output = %q, want a no-overrides message", out) - } - }) -} - -func TestFlagGet(t *testing.T) { - t.Run("detail view for a named feature", func(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - root := tempRepo(t) - writeConfig(t, root, `{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN", "apiUrl": "`+f.srv.URL+`"}`) - setMasterKey(t, f.srv.URL) - - // When - out, err := run("", "flag", "get", "max_items") - - // Then - if err != nil { - t.Fatalf("flag get: %v\noutput: %s", err, out) - } - for _, want := range []string{"max_items", "Value", "25", "Segment overrides", "Identity overrides", "Code references", "3"} { - if !strings.Contains(out, want) { - t.Errorf("output = %q, want it to contain %q", out, want) - } - } - }) - - t.Run("json is the curated shape", func(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - root := tempRepo(t) - writeConfig(t, root, `{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN", "apiUrl": "`+f.srv.URL+`"}`) - setMasterKey(t, f.srv.URL) - - // When - out, err := run("", "flag", "get", "max_items", "--json") - - // Then - if err != nil { - t.Fatalf("flag get --json: %v", err) - } - var v map[string]any - if err := json.Unmarshal([]byte(out), &v); err != nil { - t.Fatalf("parsing %q: %v", out, err) - } - if v["feature"] != "max_items" || v["type"] != "standard" || - v["enabled"] != false || v["value"] != float64(25) || - v["segment_overrides"] != float64(1) || v["identity_overrides"] != float64(2) || - v["code_references"] != float64(3) { - t.Errorf("curated view = %+v", v) - } - if _, ok := v["environment_feature_state"]; ok { - t.Errorf("view = %+v, want no nested state", v) - } - }) - - t.Run("case-insensitive exact match, not a contains match", func(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - f.features["101"] = []map[string]any{ - {"id": 1, "name": "checkout", "type": "STANDARD", - "environment_feature_state": map[string]any{"enabled": true, "feature_state_value": "a"}}, - {"id": 2, "name": "checkout-v2", "type": "STANDARD", - "environment_feature_state": map[string]any{"enabled": false, "feature_state_value": "b"}}, - } - root := tempRepo(t) - writeConfig(t, root, `{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN", "apiUrl": "`+f.srv.URL+`"}`) - setMasterKey(t, f.srv.URL) - - // When - out, err := run("", "flag", "get", "CheckOut") - - // Then - if err != nil { - t.Fatalf("flag get: %v", err) - } - if strings.Contains(out, "checkout-v2") { - t.Errorf("output = %q, matched the contains sibling instead of the exact name", out) - } - if !strings.Contains(out, "a") { - t.Errorf("output = %q, want checkout's value", out) - } - }) - - t.Run("unknown feature errors", func(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - root := tempRepo(t) - writeConfig(t, root, `{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN", "apiUrl": "`+f.srv.URL+`"}`) - setMasterKey(t, f.srv.URL) - - // When / Then - _, err := run("", "flag", "get", "ghost") - if err == nil || !strings.Contains(err.Error(), "ghost") { - t.Errorf("err = %v, want a not-found error naming the feature", err) - } - }) - - t.Run("a null identity-override count shows 0", func(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - f.features["101"] = []map[string]any{{ - "id": 1, "name": "edgeflag", "type": "STANDARD", - "num_segment_overrides": 0, "num_identity_overrides": nil, - "environment_feature_state": map[string]any{"enabled": true, "feature_state_value": "x"}, - }} - root := tempRepo(t) - writeConfig(t, root, `{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN", "apiUrl": "`+f.srv.URL+`"}`) - setMasterKey(t, f.srv.URL) - - // When - out, err := run("", "flag", "get", "edgeflag") - - // Then - if err != nil { - t.Fatalf("flag get: %v", err) - } - if !strings.Contains(out, "Identity overrides") || !strings.Contains(out, "0") { - t.Errorf("output = %q, want Identity overrides 0", out) - } - }) -} - -// flagUpdateEnv writes a config bound to project 101 / Development and returns -// the fake instance with admin credentials set. -func flagUpdateEnv(t *testing.T) *fakeInstance { - t.Helper() - isolateStorage(t) - f := newFakeInstance(t) - root := tempRepo(t) - writeConfig(t, root, `{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN", "apiUrl": "`+f.srv.URL+`"}`) - setMasterKey(t, f.srv.URL) - return f -} - -func TestFlagUpdate(t *testing.T) { - t.Run("--enable preserves the current value and reprints", func(t *testing.T) { - // Given - f := flagUpdateEnv(t) - - // When - out, err := run("", "flag", "update", "max_items", "--enable", "--yes") - - // Then - if err != nil { - t.Fatalf("flag update: %v\noutput: %s", err, out) - } - def := f.lastUpdate["environment_default"].(map[string]any) - val := def["value"].(map[string]any) - if def["enabled"] != true || val["type"] != "integer" || val["value"] != "25" { - t.Errorf("environment_default = %+v", def) - } - if !strings.Contains(out, "Enabled max_items") { - t.Errorf("output = %q, want an Enabled confirmation", out) - } - }) - - t.Run("--value infers integer", func(t *testing.T) { - // Given - f := flagUpdateEnv(t) - - // When - out, err := run("", "flag", "update", "onboarding_banner", "--value", "42", "--yes") - - // Then - if err != nil { - t.Fatalf("flag update: %v", err) - } - val := f.lastUpdate["environment_default"].(map[string]any)["value"].(map[string]any) - if val["type"] != "integer" || val["value"] != "42" { - t.Errorf("value = %+v, want inferred integer 42", val) - } - if !strings.Contains(out, "Set onboarding_banner to 42") { - t.Errorf("output = %q", out) - } - }) - - t.Run("--type string keeps a numeric literal as a string, quoted in message", func(t *testing.T) { - // Given - f := flagUpdateEnv(t) - - // When - out, err := run("", "flag", "update", "max_items", "--value", "25", "--type", "string", "--yes") - - // Then - if err != nil { - t.Fatalf("flag update: %v", err) - } - val := f.lastUpdate["environment_default"].(map[string]any)["value"].(map[string]any) - if val["type"] != "string" || val["value"] != "25" { - t.Errorf("value = %+v, want string \"25\"", val) - } - if !strings.Contains(out, `Set max_items to "25"`) { - t.Errorf("output = %q, want the string value quoted", out) - } - }) - - t.Run("--enable and --disable conflict", func(t *testing.T) { - f := flagUpdateEnv(t) - _, err := run("", "flag", "update", "max_items", "--enable", "--disable", "--yes") - var ue *usageError - if !errors.As(err, &ue) || !strings.Contains(err.Error(), "mutually exclusive") { - t.Errorf("err = %v, want a usage error", err) - } - _ = f - }) - - t.Run("nothing to update errors", func(t *testing.T) { - f := flagUpdateEnv(t) - _, err := run("", "flag", "update", "max_items", "--yes") - var ue *usageError - if !errors.As(err, &ue) || !strings.Contains(err.Error(), "nothing to update") { - t.Errorf("err = %v, want a usage error", err) - } - _ = f - }) - - t.Run("workflow-gated environment is reported clearly", func(t *testing.T) { - f := flagUpdateEnv(t) - withWorkflowGating(f) - _, err := run("", "flag", "update", "max_items", "--enable", "--yes") - if !errors.Is(err, api.ErrWorkflowGated) { - t.Errorf("err = %v, want ErrWorkflowGated", err) - } - }) - - t.Run("unknown feature errors before any write", func(t *testing.T) { - f := flagUpdateEnv(t) - _, err := run("", "flag", "update", "ghost", "--enable", "--yes") - if err == nil || !strings.Contains(err.Error(), "ghost") { - t.Errorf("err = %v, want a not-found error", err) - } - if f.lastUpdate != nil { - t.Errorf("lastUpdate = %+v, want no write attempted", f.lastUpdate) - } - }) - - t.Run("without --yes and no TTY exits 2", func(t *testing.T) { - f := flagUpdateEnv(t) - _, err := run("", "flag", "update", "max_items", "--enable") - var ue *usageError - if !errors.As(err, &ue) { - t.Errorf("err = %v, want a usage error (confirmation needed)", err) - } - if f.lastUpdate != nil { - t.Errorf("lastUpdate = %+v, want no write without confirmation", f.lastUpdate) - } - }) -} - -// withFeatureSegments registers the feature-segment rows (segment name + -// priority metadata) the fake returns for one feature. -func withFeatureSegments(f *fakeInstance, featureID int, rows ...map[string]any) { - f.mu.Lock() - defer f.mu.Unlock() - if f.featureSegments == nil { - f.featureSegments = map[string][]map[string]any{} - } - f.featureSegments[strconv.Itoa(featureID)] = rows -} - -// withFeatureStates registers the admin featurestates rows the fake returns -// for one feature. -func withFeatureStates(f *fakeInstance, featureID int, rows ...map[string]any) { - f.mu.Lock() - defer f.mu.Unlock() - if f.featureStates == nil { - f.featureStates = map[string][]map[string]any{} - } - f.featureStates[strconv.Itoa(featureID)] = rows -} - -// The fake serves requests concurrently (see fsPeak), so every field a handler -// reads is set through a locked setter rather than assigned directly. - -// withWorkflowGating makes the update endpoints answer 403. -func withWorkflowGating(f *fakeInstance) { - f.mu.Lock() - defer f.mu.Unlock() - f.workflowGated = true -} - -// withMissingSegmentOverride makes delete-segment-override answer 404. -func withMissingSegmentOverride(f *fakeInstance) { - f.mu.Lock() - defer f.mu.Unlock() - f.segmentMissing = true -} - -// withEdgeIdentities makes the project report use_edge_identities. -func withEdgeIdentities(f *fakeInstance) { - f.mu.Lock() - defer f.mu.Unlock() - f.useEdge = true -} - -// withFeatureSegmentDelay adds latency to feature-segments, so overlapping -// requests are observable in fsPeak. -func withFeatureSegmentDelay(f *fakeInstance, d time.Duration) { - f.mu.Lock() - defer f.mu.Unlock() - f.fsDelay = d -} - -// withSegmentOverride sets project 101's features to a single max_items -// feature carrying an environment default and, optionally, a segment override. -func withSegmentOverride(f *fakeInstance, withOverride bool) { - item := map[string]any{ - "id": 2, "name": "max_items", "type": "STANDARD", - "num_segment_overrides": 1, "num_identity_overrides": 0, - "environment_feature_state": map[string]any{"enabled": false, "feature_state_value": 25}, - } - if withOverride { - item["segment_feature_state"] = map[string]any{"enabled": true, "feature_state_value": "special"} - } - f.features["101"] = []map[string]any{item} -} - -func TestFlagGetSegment(t *testing.T) { - t.Run("shows the segment override", func(t *testing.T) { - // Given - f := flagUpdateEnv(t) - withSegmentOverride(f, true) - - // When - out, err := run("", "flag", "get", "max_items", "--segment", "12") - - // Then - if err != nil { - t.Fatalf("flag get --segment: %v\noutput: %s", err, out) - } - if got := f.featuresSeg(); got != "12" { - t.Errorf("features segment = %q, want 12", got) - } - for _, want := range []string{"Segment", "12", "special", "on"} { - if !strings.Contains(out, want) { - t.Errorf("output = %q, want %q", out, want) - } - } - }) - - t.Run("no override errors", func(t *testing.T) { - // Given - f := flagUpdateEnv(t) - withSegmentOverride(f, false) - - // When / Then - _, err := run("", "flag", "get", "max_items", "--segment", "99") - if err == nil || !strings.Contains(err.Error(), "segment 99") { - t.Errorf("err = %v, want a no-override error naming the segment", err) - } - }) -} - -func TestSegmentOverridePriorityView(t *testing.T) { - // The priority and the segment's {id, name} come from the feature-segments - // endpoint. - overrideMeta := func(f *fakeInstance) { - withFeatureSegments(f, 2, map[string]any{ - "id": 1200, "segment": 12, "segment_name": "powerusers", "priority": 1, "environment": 1, - }) - } - - t.Run("get --segment shows the priority and segment name", func(t *testing.T) { - f := flagUpdateEnv(t) - withSegmentOverride(f, true) - overrideMeta(f) - - out, err := run("", "flag", "get", "max_items", "--segment", "12") - if err != nil { - t.Fatalf("flag get --segment: %v\noutput: %s", err, out) - } - for _, want := range []string{"Priority", "1", "powerusers (12)"} { - if !strings.Contains(out, want) { - t.Errorf("output = %q, want %q", out, want) - } - } - }) - - t.Run("get --segment --json carries segment {id,name} and priority", func(t *testing.T) { - f := flagUpdateEnv(t) - withSegmentOverride(f, true) - overrideMeta(f) - - out, err := run("", "flag", "get", "max_items", "--segment", "12", "--json") - if err != nil { - t.Fatal(err) - } - var v map[string]any - if err := json.Unmarshal([]byte(out), &v); err != nil { - t.Fatalf("parsing %q: %v", out, err) - } - seg, _ := v["segment"].(map[string]any) - if seg == nil || seg["id"] != float64(12) || seg["name"] != "powerusers" { - t.Errorf("segment = %+v, want an {id, name} object", v["segment"]) - } - if v["priority"] != float64(1) { - t.Errorf("priority = %v, want 1", v["priority"]) - } - }) - - t.Run("update --segment reprints the detail with priority", func(t *testing.T) { - f := flagUpdateEnv(t) - withSegmentOverride(f, true) - overrideMeta(f) - - out, err := run("", "flag", "update", "max_items", "--segment", "12", "--value", "new", "--yes") - if err != nil { - t.Fatalf("flag update --segment: %v\noutput: %s", err, out) - } - if !strings.Contains(out, "Priority") || !strings.Contains(out, "powerusers (12)") { - t.Errorf("output = %q, want the detail view with Priority and the segment name", out) - } - }) - - t.Run("missing metadata degrades to the bare id", func(t *testing.T) { - f := flagUpdateEnv(t) - withSegmentOverride(f, true) // no feature-segments row registered - - out, err := run("", "flag", "get", "max_items", "--segment", "12") - if err != nil { - t.Fatalf("flag get --segment: %v\noutput: %s", err, out) - } - if !strings.Contains(out, "12") || strings.Contains(out, "(12)") { - t.Errorf("output = %q, want the bare segment id without a name", out) - } - }) -} - -func TestFlagEnableDisable(t *testing.T) { - t.Run("enable turns the environment default on, preserving value", func(t *testing.T) { - f := flagUpdateEnv(t) // max_items is off, integer 25 - out, err := run("", "flag", "enable", "max_items", "--yes") - if err != nil { - t.Fatalf("flag enable: %v\noutput: %s", err, out) - } - def := f.lastUpdate["environment_default"].(map[string]any) - val := def["value"].(map[string]any) - if def["enabled"] != true || val["type"] != "integer" || val["value"] != "25" { - t.Errorf("environment_default = %+v, want enabled with the value carried", def) - } - if !strings.Contains(out, "Enabled max_items") { - t.Errorf("output = %q, want an Enabled confirmation", out) - } - }) - - t.Run("disable turns it off", func(t *testing.T) { - f := flagUpdateEnv(t) - out, err := run("", "flag", "disable", "max_items", "--yes") - if err != nil { - t.Fatalf("flag disable: %v", err) - } - if f.lastUpdate["environment_default"].(map[string]any)["enabled"] != false { - t.Errorf("environment_default = %+v, want disabled", f.lastUpdate["environment_default"]) - } - if !strings.Contains(out, "Disabled max_items") { - t.Errorf("output = %q", out) - } - }) - - t.Run("enable targets a segment override", func(t *testing.T) { - f := flagUpdateEnv(t) - withSegmentOverride(f, false) - if _, err := run("", "flag", "enable", "max_items", "--segment", "7", "--yes"); err != nil { - t.Fatalf("flag enable --segment: %v", err) - } - ov := f.lastUpdate["segment_overrides"].([]any)[0].(map[string]any) - if ov["segment_id"] != float64(7) || ov["enabled"] != true { - t.Errorf("segment override = %+v, want enabled for segment 7", ov) - } - }) - - t.Run("--segment and --identifier are mutually exclusive", func(t *testing.T) { - flagUpdateEnv(t) - _, err := run("", "flag", "enable", "max_items", "--segment", "7", "--identifier", "u1", "--yes") - var ue *usageError - if !errors.As(err, &ue) { - t.Errorf("err = %v, want a usageError", err) - } - }) -} - -func TestFlagUpdateSegment(t *testing.T) { - t.Run("updates the override and carries the env default unchanged", func(t *testing.T) { - // Given - f := flagUpdateEnv(t) - withSegmentOverride(f, true) - - // When - out, err := run("", "flag", "update", "max_items", "--segment", "12", "--value", "new", "--yes") - - // Then - if err != nil { - t.Fatalf("flag update --segment: %v\noutput: %s", err, out) - } - def := f.lastUpdate["environment_default"].(map[string]any) - defVal := def["value"].(map[string]any) - if def["enabled"] != false || defVal["type"] != "integer" || defVal["value"] != "25" { - t.Errorf("environment_default = %+v, want the current default carried unchanged", def) - } - ovs := f.lastUpdate["segment_overrides"].([]any) - ov := ovs[0].(map[string]any) - ovVal := ov["value"].(map[string]any) - if ov["segment_id"] != float64(12) || ov["enabled"] != true || - ovVal["type"] != "string" || ovVal["value"] != "new" { - t.Errorf("segment override = %+v, want enabled preserved and value \"new\"", ov) - } - if !strings.Contains(out, `Set max_items to "new" for segment 12 in environment`) { - t.Errorf("output = %q", out) - } - }) - - t.Run("a new override inherits the env default state", func(t *testing.T) { - // Given - f := flagUpdateEnv(t) - - // When - out, err := run("", "flag", "update", "onboarding_banner", "--segment", "7", "--value", "yo", "--yes") - - // Then - if err != nil { - t.Fatalf("flag update --segment: %v\noutput: %s", err, out) - } - ov := f.lastUpdate["segment_overrides"].([]any)[0].(map[string]any) - if ov["enabled"] != true { - t.Errorf("segment override = %+v, want enabled inherited from the env default (on)", ov) - } - }) - - t.Run("a new override inherits the env default value", func(t *testing.T) { - // Given - f := flagUpdateEnv(t) - withSegmentOverride(f, false) - - // When - _, err := run("", "flag", "update", "max_items", "--segment", "7", "--enable", "--yes") - - // Then - if err != nil { - t.Fatalf("flag update --segment: %v", err) - } - ov := f.lastUpdate["segment_overrides"].([]any)[0].(map[string]any) - ovVal := ov["value"].(map[string]any) - if ov["segment_id"] != float64(7) || ov["enabled"] != true || - ovVal["type"] != "integer" || ovVal["value"] != "25" { - t.Errorf("segment override = %+v, want inherited integer 25", ov) - } - }) -} - -// A mutation's post-update detail renders from the state the command itself -// wrote — the request carried it in full — so the expensive features list is -// fetched exactly once per invocation. -func TestFlagUpdateRendersWithoutRefetch(t *testing.T) { - t.Run("enable renders the new state from one features fetch", func(t *testing.T) { - // Given - f := flagUpdateEnv(t) - - // When - out, err := run("", "flag", "enable", "max_items", "--yes") - - // Then - if err != nil { - t.Fatalf("flag enable: %v\noutput: %s", err, out) - } - if !strings.Contains(out, "on") || !strings.Contains(out, "25") { - t.Errorf("output = %q, want the updated state (on, value 25)", out) - } - if got := f.featuresCalls(); got != 1 { - t.Errorf("features list calls = %d, want 1", got) - } - }) - - t.Run("segment update reads override metadata once", func(t *testing.T) { - // Given - f := flagUpdateEnv(t) - withSegmentOverride(f, true) - withFeatureSegments(f, 2, map[string]any{ - "id": 1200, "segment": 12, "segment_name": "powerusers", "priority": 1, "environment": 1, - }) - - // When - out, err := run("", "flag", "update", "max_items", "--segment", "12", "--value", "new", "--yes") - - // Then - if err != nil { - t.Fatalf("flag update --segment: %v\noutput: %s", err, out) - } - for _, want := range []string{"Priority", "powerusers (12)", "new"} { - if !strings.Contains(out, want) { - t.Errorf("output = %q, want %q", out, want) - } - } - if got := f.featuresCalls(); got != 1 { - t.Errorf("features list calls = %d, want 1", got) - } - if got := f.featureSegmentsCalls(); got != 1 { - t.Errorf("feature-segments calls = %d, want 1", got) - } - }) - - t.Run("a priority move reprints the target priority", func(t *testing.T) { - // Given - f := flagUpdateEnv(t) - withSegmentOverride(f, true) - f.features["101"][0]["num_segment_overrides"] = 2 - withFeatureSegments(f, 2, - map[string]any{"id": 1199, "segment": 9, "segment_name": "beta", "priority": 0, "environment": 1}, - map[string]any{"id": 1200, "segment": 12, "segment_name": "powerusers", "priority": 1, "environment": 1}, - ) - - // When - out, err := run("", "flag", "update", "max_items", "--segment", "12", "--priority", "0", "--yes") - - // Then - if err != nil { - t.Fatalf("flag update --priority: %v\noutput: %s", err, out) - } - if !strings.Contains(out, "Priority") || !strings.Contains(out, "0") { - t.Errorf("output = %q, want priority 0 in the detail", out) - } - }) - - t.Run("a new override reports the appended priority", func(t *testing.T) { - // Given - f := flagUpdateEnv(t) - withSegmentOverride(f, false) // num_segment_overrides: 1 - - // When - out, err := run("", "flag", "update", "max_items", "--segment", "7", "--enable", "--yes") - - // Then - if err != nil { - t.Fatalf("flag update --segment: %v\noutput: %s", err, out) - } - if !strings.Contains(out, "Priority") || !strings.Contains(out, "1") { - t.Errorf("output = %q, want appended priority 1", out) - } - }) -} - -func TestFlagSegmentByName(t *testing.T) { - // The fake project has segments 42 "us-adults" and 57 "beta-optin". - t.Run("flag list --segment resolves a name", func(t *testing.T) { - f := flagUpdateEnv(t) - withSegmentOverride(f, true) - - out, err := run("", "flag", "list", "--segment", "us-adults") - if err != nil { - t.Fatalf("flag list --segment us-adults: %v\noutput: %s", err, out) - } - if f.featuresSeg() != "42" { - t.Errorf("features segment = %q, want 42 resolved from the name", f.featuresSeg()) - } - }) - - t.Run("flag get --segment resolves a name", func(t *testing.T) { - f := flagUpdateEnv(t) - withSegmentOverride(f, true) - - out, err := run("", "flag", "get", "max_items", "--segment", "us-adults") - if err != nil { - t.Fatalf("flag get --segment us-adults: %v\noutput: %s", err, out) - } - if f.featuresSeg() != "42" { - t.Errorf("features segment = %q, want 42 resolved from the name", f.featuresSeg()) - } - }) - - t.Run("flag update --segment resolves a name", func(t *testing.T) { - f := flagUpdateEnv(t) - withSegmentOverride(f, true) - - _, err := run("", "flag", "update", "max_items", "--segment", "beta-optin", "--enable", "--yes") - if err != nil { - t.Fatalf("flag update --segment beta-optin: %v", err) - } - ov := f.lastUpdate["segment_overrides"].([]any)[0].(map[string]any) - if ov["segment_id"] != float64(57) { - t.Errorf("segment_id = %v, want 57 resolved from beta-optin", ov["segment_id"]) - } - }) - - t.Run("flag disable --segment resolves a name", func(t *testing.T) { - f := flagUpdateEnv(t) - withSegmentOverride(f, true) - - _, err := run("", "flag", "disable", "max_items", "--segment", "us-adults", "--yes") - if err != nil { - t.Fatalf("flag disable --segment us-adults: %v", err) - } - ov := f.lastUpdate["segment_overrides"].([]any)[0].(map[string]any) - if ov["segment_id"] != float64(42) || ov["enabled"] != false { - t.Errorf("segment override = %+v, want segment 42 disabled", ov) - } - }) - - t.Run("flag delete --segment resolves a name", func(t *testing.T) { - f := flagUpdateEnv(t) - withSegmentOverride(f, true) - - _, err := run("", "flag", "delete", "max_items", "--segment", "us-adults", "--yes") - if err != nil { - t.Fatalf("flag delete --segment us-adults: %v", err) - } - if f.lastDelete["segment"].(map[string]any)["id"] != float64(42) { - t.Errorf("delete body = %+v, want segment id 42", f.lastDelete) - } - }) - - t.Run("resolution seeds the segment name cache", func(t *testing.T) { - f := flagUpdateEnv(t) - withSegmentOverride(f, true) - - if _, err := run("", "flag", "list", "--segment", "us-adults"); err != nil { - t.Fatalf("flag list --segment us-adults: %v", err) - } - names := cache.Load(f.srv.URL) - if names.Segments["42"] != "us-adults" || names.Segments["57"] != "beta-optin" { - t.Errorf("cached segments = %+v, want the listed segments merged", names.Segments) - } - }) - - t.Run("delete names the segment from the cache", func(t *testing.T) { - f := flagUpdateEnv(t) - withSegmentOverride(f, true) - - // Resolving the name lists segments, which warms the cache the - // delete message reads. - out, err := run("", "flag", "delete", "max_items", "--segment", "us-adults", "--yes") - if err != nil { - t.Fatalf("flag delete --segment us-adults: %v\noutput: %s", err, out) - } - if !strings.Contains(out, "Deleted max_items override for segment us-adults (42) in environment") { - t.Errorf("output = %q, want the segment named from the cache", out) - } - }) - - t.Run("unknown segment name errors with the segment list hint", func(t *testing.T) { - f := flagUpdateEnv(t) - - _, err := run("", "flag", "list", "--segment", "ghost") - if err == nil || !strings.Contains(err.Error(), "ghost") { - t.Errorf("err = %v, want a not-found error naming the segment", err) - } - if hint := hintFor(err); !strings.Contains(hint, "segment list") { - t.Errorf("hint = %q, want the segment list hint", hint) - } - _ = f - }) -} - -// withFeatureOverridesFixture arranges max_items (feature 2) with two segment -// overrides: beta-optin (57) at priority 0 value "blue", us-adults (42) at -// priority 1 value 25 (disabled). Row 9000 is the environment default, which -// override listings must skip. -func withFeatureOverridesFixture(f *fakeInstance) { - withSegmentOverride(f, true) - withFeatureSegments(f, 2, - map[string]any{"id": 1200, "segment": 57, "segment_name": "beta-optin", "priority": 0, "environment": 1}, - map[string]any{"id": 1201, "segment": 42, "segment_name": "us-adults", "priority": 1, "environment": 1}, - ) - str := func(s string) map[string]any { return map[string]any{"type": "unicode", "string_value": s} } - withFeatureStates(f, 2, - map[string]any{"id": 9000, "feature_segment": nil, "enabled": false, "feature_state_value": str("default")}, - map[string]any{"id": 9001, "feature_segment": 1200, "enabled": true, "feature_state_value": str("blue")}, - map[string]any{"id": 9002, "feature_segment": 1201, "enabled": false, "feature_state_value": map[string]any{"type": "int", "integer_value": 25}}, - ) -} - -func TestFlagListFeatureOverrides(t *testing.T) { - t.Run("lists a feature's overrides in priority order", func(t *testing.T) { - f := flagUpdateEnv(t) - withFeatureOverridesFixture(f) - - out, err := run("", "flag", "list", "--feature", "max_items") - if err != nil { - t.Fatalf("flag list --feature: %v\noutput: %s", err, out) - } - for _, want := range []string{ - "PRIORITY", "SEGMENT", "STATE", "VALUE", - "beta-optin (57)", "us-adults (42)", "blue", "25", "2 overrides", - } { - if !strings.Contains(out, want) { - t.Errorf("output = %q, want %q", out, want) - } - } - if beta, us := strings.Index(out, "beta-optin"), strings.Index(out, "us-adults"); beta > us { - t.Errorf("output = %q, want beta-optin (priority 0) before us-adults (priority 1)", out) - } - if strings.Contains(out, "default") { - t.Errorf("output = %q, want the environment default row skipped", out) - } - }) - - t.Run("--json is an array of the override shape in priority order", func(t *testing.T) { - f := flagUpdateEnv(t) - withFeatureOverridesFixture(f) - - out, err := run("", "flag", "list", "--feature", "max_items", "--json") - if err != nil { - t.Fatal(err) - } - var arr []map[string]any - if err := json.Unmarshal([]byte(out), &arr); err != nil { - t.Fatalf("parsing %q: %v", out, err) - } - if len(arr) != 2 { - t.Fatalf("items = %+v, want 2 overrides", arr) - } - first := arr[0] - seg, _ := first["segment"].(map[string]any) - if first["feature"] != "max_items" || first["priority"] != float64(0) || - seg == nil || seg["id"] != float64(57) || seg["name"] != "beta-optin" || - first["enabled"] != true || first["value"] != "blue" { - t.Errorf("first = %+v", first) - } - if arr[1]["value"] != float64(25) || arr[1]["enabled"] != false { - t.Errorf("second = %+v, want the typed int value as a scalar", arr[1]) - } - }) - - t.Run("--feature accepts an id", func(t *testing.T) { - f := flagUpdateEnv(t) - withFeatureOverridesFixture(f) - - out, err := run("", "flag", "list", "--feature", "2") - if err != nil { - t.Fatalf("flag list --feature 2: %v\noutput: %s", err, out) - } - if !strings.Contains(out, "beta-optin") { - t.Errorf("output = %q", out) - } - }) - - t.Run("--feature and --segment are mutually exclusive", func(t *testing.T) { - f := flagUpdateEnv(t) - _, err := run("", "flag", "list", "--feature", "max_items", "--segment", "12") - var ue *usageError - if !errors.As(err, &ue) || !strings.Contains(err.Error(), "mutually exclusive") { - t.Errorf("err = %v, want a usage error", err) - } - _ = f - }) - - t.Run("unknown feature errors with the flag list hint", func(t *testing.T) { - f := flagUpdateEnv(t) - _, err := run("", "flag", "list", "--feature", "ghost") - if err == nil || !strings.Contains(err.Error(), "ghost") { - t.Errorf("err = %v, want a not-found error", err) - } - if hint := hintFor(err); !strings.Contains(hint, "flag list") { - t.Errorf("hint = %q, want the flag list hint", hint) - } - _ = f - }) - - t.Run("no overrides", func(t *testing.T) { - f := flagUpdateEnv(t) - withSegmentOverride(f, true) // feature exists; no feature-segment rows - - out, err := run("", "flag", "list", "--feature", "max_items") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(out, "No segment overrides") { - t.Errorf("output = %q, want a no-overrides message", out) - } - }) -} - -func TestFlagListIdentityOverrides(t *testing.T) { - t.Run("lists core identity overrides", func(t *testing.T) { - f := flagUpdateEnv(t) - withSegmentOverride(f, true) - f.mu.Lock() - f.coreIdentities = map[string]int{"id-123": 501, "id-456": 502} - f.coreOverrides = map[int]map[int]*fakeFS{ - 501: {2: {id: 9100, enabled: true, value: "hero"}}, - 502: {2: {id: 9101, enabled: false, value: "hello"}}, - } - f.mu.Unlock() - - out, err := run("", "flag", "list", "--feature", "max_items", "--identities") - if err != nil { - t.Fatalf("flag list --feature --identities: %v\noutput: %s", err, out) - } - for _, want := range []string{ - "IDENTIFIER", "STATE", "VALUE", - "id-123", "hero", "id-456", "hello", "2 overrides", - } { - if !strings.Contains(out, want) { - t.Errorf("output = %q, want %q", out, want) - } - } - }) - - t.Run("--json is the identity override shape", func(t *testing.T) { - f := flagUpdateEnv(t) - withSegmentOverride(f, true) - f.mu.Lock() - f.coreIdentities = map[string]int{"id-123": 501} - f.coreOverrides = map[int]map[int]*fakeFS{501: {2: {id: 9100, enabled: true, value: "hero"}}} - f.mu.Unlock() - - out, err := run("", "flag", "list", "--feature", "max_items", "--identities", "--json") - if err != nil { - t.Fatal(err) - } - var arr []map[string]any - if err := json.Unmarshal([]byte(out), &arr); err != nil { - t.Fatalf("parsing %q: %v", out, err) - } - if len(arr) != 1 || arr[0]["identifier"] != "id-123" || - arr[0]["enabled"] != true || arr[0]["value"] != "hero" || - arr[0]["feature"] != "max_items" { - t.Errorf("items = %+v", arr) - } - }) - - t.Run("edge projects use the edge endpoint", func(t *testing.T) { - f := flagUpdateEnv(t) - withSegmentOverride(f, true) - f.mu.Lock() - f.useEdge = true - f.edgeOverrides = map[string]map[int]*fakeFS{ - "edge-user": {2: {enabled: true, value: "x"}}, - } - f.mu.Unlock() - - out, err := run("", "flag", "list", "--feature", "max_items", "--identities") - if err != nil { - t.Fatalf("flag list --identities (edge): %v\noutput: %s", err, out) - } - if !strings.Contains(out, "edge-user") || !strings.Contains(out, "1 override") { - t.Errorf("output = %q, want the edge override listed", out) - } - }) - - t.Run("--identities without --feature exits 2", func(t *testing.T) { - f := flagUpdateEnv(t) - _, err := run("", "flag", "list", "--identities") - var ue *usageError - if !errors.As(err, &ue) || !strings.Contains(err.Error(), "--feature") { - t.Errorf("err = %v, want a usage error naming --feature", err) - } - _ = f - }) -} - -func TestFlagUpdatePriority(t *testing.T) { - // max_items (feature 2) has one override, for segment 12 at priority 1. - overrideMeta := func(f *fakeInstance) { - withFeatureSegments(f, 2, map[string]any{ - "id": 1200, "segment": 12, "segment_name": "powerusers", "priority": 1, "environment": 1, - }) - } - - t.Run("sends the priority in the segment override", func(t *testing.T) { - f := flagUpdateEnv(t) - withSegmentOverride(f, true) - overrideMeta(f) - - out, err := run("", "flag", "update", "max_items", "--segment", "12", "--priority", "0", "--yes") - if err != nil { - t.Fatalf("flag update --priority: %v\noutput: %s", err, out) - } - ov := f.lastUpdate["segment_overrides"].([]any)[0].(map[string]any) - if ov["priority"] != float64(0) { - t.Errorf("override = %+v, want priority 0", ov) - } - // The override's current state rides along unchanged. - ovVal := ov["value"].(map[string]any) - if ov["enabled"] != true || ovVal["value"] != "special" { - t.Errorf("override = %+v, want current state echoed", ov) - } - if !strings.Contains(out, "Set max_items priority to 0 for segment powerusers (12) in environment") { - t.Errorf("output = %q, want a priority confirmation naming the segment", out) - } - if !strings.Contains(out, "Priority") { - t.Errorf("output = %q, want the detail reprint with Priority", out) - } - }) - - t.Run("composes with --enable in the same request", func(t *testing.T) { - f := flagUpdateEnv(t) - withSegmentOverride(f, true) - overrideMeta(f) - - _, err := run("", "flag", "update", "max_items", "--segment", "12", "--disable", "--priority", "0", "--yes") - if err != nil { - t.Fatalf("flag update: %v", err) - } - ov := f.lastUpdate["segment_overrides"].([]any)[0].(map[string]any) - if ov["priority"] != float64(0) || ov["enabled"] != false { - t.Errorf("override = %+v, want priority 0 and disabled in one request", ov) - } - }) - - t.Run("without --segment exits 2", func(t *testing.T) { - f := flagUpdateEnv(t) - _, err := run("", "flag", "update", "max_items", "--priority", "0", "--yes") - var ue *usageError - if !errors.As(err, &ue) || !strings.Contains(err.Error(), "--segment") { - t.Errorf("err = %v, want a usage error naming --segment", err) - } - if f.lastUpdate != nil { - t.Errorf("lastUpdate = %+v, want no write", f.lastUpdate) - } - }) - - t.Run("out of range exits 2 before any write", func(t *testing.T) { - f := flagUpdateEnv(t) - withSegmentOverride(f, true) // num_segment_overrides: 1 → valid range 0..0 - overrideMeta(f) - - _, err := run("", "flag", "update", "max_items", "--segment", "12", "--priority", "5", "--yes") - var ue *usageError - if !errors.As(err, &ue) || !strings.Contains(err.Error(), "--priority") { - t.Errorf("err = %v, want a usage error naming --priority", err) - } - if f.lastUpdate != nil { - t.Errorf("lastUpdate = %+v, want no write", f.lastUpdate) - } - }) -} - -func TestFlagFeatureByID(t *testing.T) { - // Default features: onboarding_banner (1), max_items (2). - t.Run("get accepts a feature id", func(t *testing.T) { - _ = flagUpdateEnv(t) - - out, err := run("", "flag", "get", "2") - if err != nil { - t.Fatalf("flag get 2: %v\noutput: %s", err, out) - } - if !strings.Contains(out, "max_items") { - t.Errorf("output = %q, want the feature resolved by id", out) - } - }) - - t.Run("update by id sends and prints the canonical name", func(t *testing.T) { - f := flagUpdateEnv(t) - - out, err := run("", "flag", "update", "2", "--enable", "--yes") - if err != nil { - t.Fatalf("flag update 2: %v\noutput: %s", err, out) - } - feature := f.lastUpdate["feature"].(map[string]any) - if feature["name"] != "max_items" { - t.Errorf("feature ref = %+v, want the canonical name on the wire", feature) - } - if !strings.Contains(out, "Enabled max_items") { - t.Errorf("output = %q, want the canonical name in the message", out) - } - }) - - t.Run("delete --segment by id targets the id on the wire", func(t *testing.T) { - f := flagUpdateEnv(t) - - _, err := run("", "flag", "delete", "2", "--segment", "12", "--yes") - if err != nil { - t.Fatalf("flag delete 2: %v", err) - } - if f.lastDelete["feature"].(map[string]any)["id"] != float64(2) { - t.Errorf("delete body = %+v, want the feature targeted by id", f.lastDelete) - } - }) - - t.Run("reorder accepts a feature id", func(t *testing.T) { - f := flagUpdateEnv(t) - withFeatureOverridesFixture(f) - - _, err := run("", "flag", "reorder", "2", "us-adults", "beta-optin", "--yes") - if err != nil { - t.Fatalf("flag reorder 2: %v", err) - } - if f.lastUpdate["feature"].(map[string]any)["name"] != "max_items" { - t.Errorf("feature ref = %+v, want the canonical name on the wire", f.lastUpdate["feature"]) - } - }) -} - -func TestFlagReorder(t *testing.T) { - // Fixture: max_items has overrides beta-optin (57, priority 0, "blue", - // on) and us-adults (42, priority 1, 25, off); env default off/25. - - t.Run("refuses to reorder when an override has no state row", func(t *testing.T) { - // Given us-adults' state row missing — as a concurrent delete would - // leave it. Echoing a zero state would disable and blank the override. - f := flagUpdateEnv(t) - withFeatureOverridesFixture(f) - str := func(s string) map[string]any { return map[string]any{"type": "unicode", "string_value": s} } - withFeatureStates(f, 2, - map[string]any{"id": 9000, "feature_segment": nil, "enabled": false, "feature_state_value": str("default")}, - map[string]any{"id": 9001, "feature_segment": 1200, "enabled": true, "feature_state_value": str("blue")}, - ) - - // When - _, err := run("", "flag", "reorder", "max_items", "us-adults", "beta-optin", "--yes") - - // Then - if err == nil || !strings.Contains(err.Error(), "42") { - t.Errorf("err = %v, want a refusal naming the segment", err) - } - f.mu.Lock() - calls := f.updateCalls - f.mu.Unlock() - if calls != 0 { - t.Errorf("update calls = %d, want no write at all", calls) - } - }) - - t.Run("re-permutes every override in one request", func(t *testing.T) { - f := flagUpdateEnv(t) - withFeatureOverridesFixture(f) - - out, err := run("", "flag", "reorder", "max_items", "us-adults", "beta-optin", "--yes") - if err != nil { - t.Fatalf("flag reorder: %v\noutput: %s", err, out) - } - f.mu.Lock() - calls := f.updateCalls - f.mu.Unlock() - if calls != 1 { - t.Errorf("update calls = %d, want the whole reorder in one request", calls) - } - ovs := f.lastUpdate["segment_overrides"].([]any) - if len(ovs) != 2 { - t.Fatalf("segment_overrides = %+v, want both overrides", ovs) - } - first := ovs[0].(map[string]any) - second := ovs[1].(map[string]any) - if first["segment_id"] != float64(42) || first["priority"] != float64(0) { - t.Errorf("first override = %+v, want us-adults at priority 0", first) - } - if second["segment_id"] != float64(57) || second["priority"] != float64(1) { - t.Errorf("second override = %+v, want beta-optin at priority 1", second) - } - // Each override echoes its current state so nothing else changes. - firstVal := first["value"].(map[string]any) - secondVal := second["value"].(map[string]any) - if first["enabled"] != false || firstVal["type"] != "integer" || firstVal["value"] != "25" { - t.Errorf("first override = %+v, want current state echoed", first) - } - if second["enabled"] != true || secondVal["value"] != "blue" { - t.Errorf("second override = %+v, want current state echoed", second) - } - // The environment default rides along unchanged. - def := f.lastUpdate["environment_default"].(map[string]any) - if def["enabled"] != false { - t.Errorf("environment_default = %+v, want carried unchanged", def) - } - if !strings.Contains(out, "Reordered 2 segment overrides for max_items") { - t.Errorf("output = %q, want a reorder confirmation", out) - } - // The resulting order is printed, us-adults now first. - if us, beta := strings.Index(out, "us-adults"), strings.Index(out, "beta-optin"); us == -1 || us > beta { - t.Errorf("output = %q, want the resulting table with us-adults first", out) - } - }) - - t.Run("resolves refs and renders from data already in hand", func(t *testing.T) { - // Given - f := flagUpdateEnv(t) - withFeatureOverridesFixture(f) - - // When - out, err := run("", "flag", "reorder", "max_items", "us-adults", "beta-optin", "--yes") - - // Then - if err != nil { - t.Fatalf("flag reorder: %v\noutput: %s", err, out) - } - if got := f.segmentsCalls(); got != 0 { - t.Errorf("segments list calls = %d, want 0 (names resolve from the override rows)", got) - } - if got := f.featureSegmentsCalls(); got != 1 { - t.Errorf("feature-segments calls = %d, want 1", got) - } - if got := f.featureStatesCalls(); got != 1 { - t.Errorf("featurestates calls = %d, want 1", got) - } - if us, beta := strings.Index(out, "us-adults"), strings.Index(out, "beta-optin"); us == -1 || us > beta { - t.Errorf("output = %q, want the resulting table with us-adults first", out) - } - }) - - t.Run("an unknown name still gets the project-level error", func(t *testing.T) { - // Given - f := flagUpdateEnv(t) - withFeatureOverridesFixture(f) - - // When - _, err := run("", "flag", "reorder", "max_items", "beta-optin", "nope", "--yes") - - // Then - if err == nil || !strings.Contains(err.Error(), `segment "nope" not found`) { - t.Errorf("err = %v, want the segment-not-found error", err) - } - if f.lastUpdate != nil { - t.Errorf("lastUpdate = %+v, want no write", f.lastUpdate) - } - }) - - t.Run("a partial list exits 2 naming the missing segments", func(t *testing.T) { - f := flagUpdateEnv(t) - withFeatureOverridesFixture(f) - - _, err := run("", "flag", "reorder", "max_items", "beta-optin", "--yes") - var ue *usageError - if !errors.As(err, &ue) || !strings.Contains(err.Error(), "us-adults") { - t.Errorf("err = %v, want a usage error naming us-adults", err) - } - if f.lastUpdate != nil { - t.Errorf("lastUpdate = %+v, want no write", f.lastUpdate) - } - }) - - t.Run("a segment without an override exits 2", func(t *testing.T) { - f := flagUpdateEnv(t) - withFeatureOverridesFixture(f) - - _, err := run("", "flag", "reorder", "max_items", "beta-optin", "us-adults", "beta-cohort", "--yes") - var ue *usageError - if !errors.As(err, &ue) || !strings.Contains(err.Error(), "beta-cohort") { - t.Errorf("err = %v, want a usage error naming beta-cohort", err) - } - if f.lastUpdate != nil { - t.Errorf("lastUpdate = %+v, want no write", f.lastUpdate) - } - }) - - t.Run("a duplicate segment exits 2", func(t *testing.T) { - f := flagUpdateEnv(t) - withFeatureOverridesFixture(f) - - _, err := run("", "flag", "reorder", "max_items", "beta-optin", "beta-optin", "--yes") - var ue *usageError - if !errors.As(err, &ue) || !strings.Contains(err.Error(), "beta-optin") { - t.Errorf("err = %v, want a usage error naming the duplicate", err) - } - if f.lastUpdate != nil { - t.Errorf("lastUpdate = %+v, want no write", f.lastUpdate) - } - }) - - t.Run("without --yes and no TTY exits 2", func(t *testing.T) { - f := flagUpdateEnv(t) - withFeatureOverridesFixture(f) - - _, err := run("", "flag", "reorder", "max_items", "us-adults", "beta-optin") - var ue *usageError - if !errors.As(err, &ue) { - t.Errorf("err = %v, want a usage error (confirmation needed)", err) - } - if f.lastUpdate != nil { - t.Errorf("lastUpdate = %+v, want no write without confirmation", f.lastUpdate) - } - }) -} - -func TestFlagDelete(t *testing.T) { - t.Run("deletes a segment override", func(t *testing.T) { - // Given - f := flagUpdateEnv(t) - - // When - out, err := run("", "flag", "delete", "max_items", "--segment", "12", "--yes") - - // Then - if err != nil { - t.Fatalf("flag delete: %v\noutput: %s", err, out) - } - if f.lastDelete["feature"].(map[string]any)["name"] != "max_items" || - f.lastDelete["segment"].(map[string]any)["id"] != float64(12) { - t.Errorf("delete body = %+v", f.lastDelete) - } - if !strings.Contains(out, "Deleted max_items override for segment 12") { - t.Errorf("output = %q", out) - } - }) - - t.Run("without --segment exits 2", func(t *testing.T) { - f := flagUpdateEnv(t) - _, err := run("", "flag", "delete", "max_items", "--yes") - var ue *usageError - if !errors.As(err, &ue) || !strings.Contains(err.Error(), "--segment") { - t.Errorf("err = %v, want a usage error naming --segment", err) - } - if f.lastDelete != nil { - t.Errorf("lastDelete = %+v, want no call", f.lastDelete) - } - }) - - t.Run("missing override reports not found", func(t *testing.T) { - f := flagUpdateEnv(t) - withMissingSegmentOverride(f) - _, err := run("", "flag", "delete", "max_items", "--segment", "99", "--yes") - if err == nil || !strings.Contains(err.Error(), "segment 99") { - t.Errorf("err = %v, want a not-found error naming the segment", err) - } - }) -} - -func TestUsageIsSingleLine(t *testing.T) { - // Given / When - out, err := run("", "--help") - if err != nil { - t.Fatal(err) - } - - // Then - if !strings.Contains(out, "flagsmith [command] [flags]") { - t.Errorf("root usage = %q, want the single-line form", out) - } - if strings.Contains(out, "flagsmith [flags]\n") { - t.Errorf("root usage still shows the two-line form:\n%s", out) - } - - leaf, err := run("", "flag", "list", "--help") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(leaf, "flagsmith flag list [flags]") { - t.Errorf("leaf usage = %q, want its own use line", leaf) - } -} - -func TestFlagIdentity(t *testing.T) { - // max_items is feature id 2 in defaultFeatures; user-1 is core identity 501. - - // -i is the identity everywhere it appears, `eval` included. - t.Run("-i is shorthand for --identifier", func(t *testing.T) { - f := flagUpdateEnv(t) - - out, err := run("", "flag", "update", "max_items", "-i", "user-1", "--value", "42", "--yes") - if err != nil { - t.Fatalf("flag update -i: %v\noutput: %s", err, out) - } - if w := f.lastIdentityWrite; w["feature"] != float64(2) || w["feature_state_value"] != float64(42) { - t.Errorf("core write = %+v, want -i to target the identity", w) - } - }) - - t.Run("-i works for get and delete too", func(t *testing.T) { - f := flagUpdateEnv(t) - f.coreOverrides[501] = map[int]*fakeFS{2: {id: 9001, enabled: true, value: 7}} - - out, err := run("", "flag", "get", "max_items", "-i", "user-1") - if err != nil { - t.Fatalf("flag get -i: %v\noutput: %s", err, out) - } - if !strings.Contains(out, "user-1") || !strings.Contains(out, "7") { - t.Errorf("output = %q, want the identity's override", out) - } - if out, err = run("", "flag", "delete", "max_items", "-i", "user-1", "--yes"); err != nil { - t.Fatalf("flag delete -i: %v\noutput: %s", err, out) - } - }) - - t.Run("core: update creates an override via the core endpoint", func(t *testing.T) { - f := flagUpdateEnv(t) // useEdge defaults to false - - out, err := run("", "flag", "update", "max_items", "--identifier", "user-1", "--value", "42", "--yes") - if err != nil { - t.Fatalf("flag update --identifier: %v\noutput: %s", err, out) - } - w := f.lastIdentityWrite - if w["feature"] != float64(2) || w["enabled"] != false || w["feature_state_value"] != float64(42) { - t.Errorf("core write = %+v", w) - } - if f.lastEdgeWrite != nil { - t.Errorf("edge endpoint should not have been used: %+v", f.lastEdgeWrite) - } - if !strings.Contains(out, "Set max_items to 42 for identifier user-1") { - t.Errorf("output = %q", out) - } - }) - - t.Run("core: get shows the override", func(t *testing.T) { - f := flagUpdateEnv(t) - f.coreOverrides[501] = map[int]*fakeFS{2: {id: 9001, enabled: true, value: "custom"}} - - out, err := run("", "flag", "get", "max_items", "--identifier", "user-1") - if err != nil { - t.Fatalf("flag get --identifier: %v", err) - } - for _, want := range []string{"Identifier", "user-1", "custom", "on"} { - if !strings.Contains(out, want) { - t.Errorf("output = %q, want %q", out, want) - } - } - }) - - t.Run("core: delete removes the override", func(t *testing.T) { - f := flagUpdateEnv(t) - f.coreOverrides[501] = map[int]*fakeFS{2: {id: 9001, enabled: true, value: "x"}} - - out, err := run("", "flag", "delete", "max_items", "--identifier", "user-1", "--yes") - if err != nil { - t.Fatalf("flag delete --identifier: %v", err) - } - if f.coreOverrides[501][2] != nil { - t.Errorf("override still present: %+v", f.coreOverrides[501][2]) - } - if !strings.Contains(out, "Deleted max_items override for identifier user-1") { - t.Errorf("output = %q", out) - } - }) - - t.Run("core: delete reports a missing identity before confirming", func(t *testing.T) { - // Given an identifier the environment has never seen, and no --yes: the - // confirmation would fail first if it came before the lookup. - flagUpdateEnv(t) - - // When - _, err := run("", "flag", "delete", "max_items", "--identifier", "ghost") - - // Then - if err == nil || !strings.Contains(err.Error(), `identity "ghost" not found`) { - t.Errorf("err = %v, want the missing identity", err) - } - if err != nil && strings.Contains(err.Error(), "--yes") { - t.Error("asked to confirm deleting an override that does not exist") - } - }) - - t.Run("edge: update via the identifier endpoint", func(t *testing.T) { - f := flagUpdateEnv(t) - withEdgeIdentities(f) - - out, err := run("", "flag", "update", "max_items", "--identifier", "edge-user", "--enable", "--value", "7", "--yes") - if err != nil { - t.Fatalf("flag update --identifier (edge): %v\noutput: %s", err, out) - } - w := f.lastEdgeWrite - if w["identifier"] != "edge-user" || w["feature"] != float64(2) || w["enabled"] != true || w["feature_state_value"] != float64(7) { - t.Errorf("edge write = %+v", w) - } - if f.lastIdentityWrite != nil { - t.Errorf("core endpoint should not have been used: %+v", f.lastIdentityWrite) - } - if !strings.Contains(out, "Set max_items to 7 for identifier edge-user") || - !strings.Contains(out, "Enabled max_items for identifier edge-user") { - t.Errorf("output = %q", out) - } - }) - - t.Run("edge: get resolves uuid then reads", func(t *testing.T) { - f := flagUpdateEnv(t) - withEdgeIdentities(f) - f.edgeOverrides["edge-user"] = map[int]*fakeFS{2: {enabled: false, value: "e"}} - - out, err := run("", "flag", "get", "max_items", "--identifier", "edge-user") - if err != nil { - t.Fatalf("flag get --identifier (edge): %v", err) - } - for _, want := range []string{"Identifier", "edge-user", "e", "off"} { - if !strings.Contains(out, want) { - t.Errorf("output = %q, want %q", out, want) - } - } - }) - - t.Run("edge: delete via the identifier endpoint", func(t *testing.T) { - f := flagUpdateEnv(t) - withEdgeIdentities(f) - f.edgeOverrides["edge-user"] = map[int]*fakeFS{2: {enabled: true, value: "e"}} - - out, err := run("", "flag", "delete", "max_items", "--identifier", "edge-user", "--yes") - if err != nil { - t.Fatalf("flag delete --identifier (edge): %v", err) - } - if f.lastEdgeDelete["identifier"] != "edge-user" || f.lastEdgeDelete["feature"] != float64(2) { - t.Errorf("edge delete body = %+v", f.lastEdgeDelete) - } - if f.edgeOverrides["edge-user"][2] != nil { - t.Errorf("edge override still present") - } - if !strings.Contains(out, "Deleted max_items override for identifier edge-user") { - t.Errorf("output = %q", out) - } - }) - - t.Run("core: update resolves the identifier once", func(t *testing.T) { - // Given - f := flagUpdateEnv(t) - f.coreOverrides[501] = map[int]*fakeFS{2: {id: 9001, enabled: false, value: "x"}} - - // When - out, err := run("", "flag", "update", "max_items", "--identifier", "user-1", "--enable", "--yes") - - // Then - if err != nil { - t.Fatalf("flag update --identifier: %v\noutput: %s", err, out) - } - if got := f.identityLookups(); got != 1 { - t.Errorf("identifier lookups = %d, want 1", got) - } - if !strings.Contains(out, "on") || !strings.Contains(out, "x") { - t.Errorf("output = %q, want the updated detail (on, value x)", out) - } - }) - - t.Run("core: a new override inherits the env default state", func(t *testing.T) { - // Given - f := flagUpdateEnv(t) - - // When - out, err := run("", "flag", "update", "onboarding_banner", "--identifier", "user-1", "--value", "yo", "--yes") - - // Then - if err != nil { - t.Fatalf("flag update --identifier: %v\noutput: %s", err, out) - } - f.mu.Lock() - w := f.lastIdentityWrite - f.mu.Unlock() - if w["enabled"] != true { - t.Errorf("core write = %+v, want enabled inherited from the env default (on)", w) - } - }) - - t.Run("core: a missing identity is created after one lookup", func(t *testing.T) { - // Given - f := flagUpdateEnv(t) - - // When - out, err := run("", "flag", "update", "max_items", "--identifier", "new-user", "--enable", "--yes") - - // Then - if err != nil { - t.Fatalf("flag update --identifier: %v\noutput: %s", err, out) - } - if got := f.identityLookups(); got != 1 { - t.Errorf("identifier lookups = %d, want 1", got) - } - f.mu.Lock() - _, created := f.coreIdentities["new-user"] - w := f.lastIdentityWrite - f.mu.Unlock() - if !created { - t.Errorf("identity new-user was not created") - } - if w["feature"] != float64(2) || w["enabled"] != true { - t.Errorf("core write = %+v, want feature 2 enabled", w) - } - }) - - t.Run("edge: update resolves the uuid once", func(t *testing.T) { - // Given - f := flagUpdateEnv(t) - withEdgeIdentities(f) - f.edgeOverrides["edge-user"] = map[int]*fakeFS{2: {enabled: false, value: "e"}} - - // When - out, err := run("", "flag", "update", "max_items", "--identifier", "edge-user", "--enable", "--yes") - - // Then - if err != nil { - t.Fatalf("flag update --identifier (edge): %v\noutput: %s", err, out) - } - if got := f.edgeIdentityLookups(); got != 1 { - t.Errorf("uuid lookups = %d, want 1", got) - } - if !strings.Contains(out, "on") { - t.Errorf("output = %q, want the updated detail", out) - } - }) - - t.Run("--segment and --identifier are mutually exclusive", func(t *testing.T) { - flagUpdateEnv(t) - _, err := run("", "flag", "update", "max_items", "--segment", "1", "--identifier", "user-1", "--yes") - var ue *usageError - if !errors.As(err, &ue) || !strings.Contains(err.Error(), "mutually exclusive") { - t.Errorf("err = %v, want a usage error", err) - } - }) - - t.Run("delete demands a target", func(t *testing.T) { - flagUpdateEnv(t) - _, err := run("", "flag", "delete", "max_items", "--yes") - var ue *usageError - if !errors.As(err, &ue) || !strings.Contains(err.Error(), "--identifier") { - t.Errorf("err = %v, want a usage error naming --segment/--identifier", err) - } - }) -} - -func TestSegmentList(t *testing.T) { - t.Run("hides feature-specific segments by default", func(t *testing.T) { - flagUpdateEnv(t) - out, err := run("", "segment", "list") - if err != nil { - t.Fatalf("segment list: %v\noutput: %s", err, out) - } - for _, want := range []string{"NAME", "ID", "CONDITIONS", "DESCRIPTION", "us-adults", "beta-optin", "2 segments"} { - if !strings.Contains(out, want) { - t.Errorf("output = %q, want %q", out, want) - } - } - if strings.Contains(out, "beta-cohort") { - t.Errorf("output = %q, should hide feature-specific beta-cohort", out) - } - }) - - t.Run("--include-feature-specific shows them", func(t *testing.T) { - flagUpdateEnv(t) - out, err := run("", "segment", "list", "--include-feature-specific") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(out, "beta-cohort") || !strings.Contains(out, "3 segments") { - t.Errorf("output = %q, want beta-cohort and 3 segments", out) - } - }) - - t.Run("--json is an array of curated segments", func(t *testing.T) { - flagUpdateEnv(t) - out, err := run("", "segment", "list", "--json") - if err != nil { - t.Fatal(err) - } - var arr []map[string]any - if err := json.Unmarshal([]byte(out), &arr); err != nil { - t.Fatalf("parsing %q: %v", out, err) - } - if len(arr) != 2 { - t.Errorf("segments = %+v, want 2", arr) - } - }) -} - -func TestSegmentGet(t *testing.T) { - t.Run("renders the rule tree and a nudge", func(t *testing.T) { - flagUpdateEnv(t) - out, err := run("", "segment", "get", "us-adults") // by name - if err != nil { - t.Fatalf("segment get: %v\noutput: %s", err, out) - } - for _, want := range []string{ - "us-adults (42)", "All of the below:", "Any of the below:", - "country", "IN", "US, CA", "age", "GREATER_THAN_INCLUSIVE", - "flag list --segment 42", - } { - if !strings.Contains(out, want) { - t.Errorf("output = %q, want %q", out, want) - } - } - }) - - t.Run("--json decodes IN to an array and stamps $schema", func(t *testing.T) { - flagUpdateEnv(t) - out, err := run("", "segment", "get", "42", "--json") // by id - if err != nil { - t.Fatal(err) - } - var v map[string]any - if err := json.Unmarshal([]byte(out), &v); err != nil { - t.Fatalf("parsing %q: %v", out, err) - } - rules := v["rules"].(map[string]any) - if rules["$schema"] == nil { - t.Errorf("rules = %+v, want a $schema pointer", rules) - } - sub := rules["rules"].([]any)[0].(map[string]any) - cond := sub["conditions"].([]any)[0].(map[string]any) - arr, ok := cond["value"].([]any) - if !ok || len(arr) != 2 || arr[0] != "US" || arr[1] != "CA" { - t.Errorf("IN value = %v, want [\"US\",\"CA\"]", cond["value"]) - } - }) -} - -func TestSegmentCreate(t *testing.T) { - t.Run("encodes IN and wraps the rule", func(t *testing.T) { - f := flagUpdateEnv(t) - rule := `{"type":"ALL","rules":[{"type":"ANY","conditions":[{"property":"country","operator":"IN","value":["US","CA"]}]}]}` - out, err := run("", "segment", "create", "newseg", "--rules", rule) - if err != nil { - t.Fatalf("segment create: %v\noutput: %s", err, out) - } - body := f.lastSegmentBody - if body["name"] != "newseg" { - t.Errorf("name = %v", body["name"]) - } - top := body["rules"].([]any)[0].(map[string]any) - sub := top["rules"].([]any)[0].(map[string]any) - cond := sub["conditions"].([]any)[0].(map[string]any) - if cond["value"] != `["US","CA"]` { - t.Errorf("IN value on the wire = %v, want the JSON-array string", cond["value"]) - } - if !strings.Contains(out, "Created segment newseg") { - t.Errorf("output = %q", out) - } - }) - - t.Run("requires --rules", func(t *testing.T) { - flagUpdateEnv(t) - _, err := run("", "segment", "create", "x") - var ue *usageError - if !errors.As(err, &ue) || !strings.Contains(err.Error(), "--rules") { - t.Errorf("err = %v, want a usage error naming --rules", err) - } - }) - - t.Run("--feature resolves a name to an id", func(t *testing.T) { - f := flagUpdateEnv(t) - rule := `{"type":"ALL","conditions":[{"property":"beta","operator":"IS_SET"}]}` - if _, err := run("", "segment", "create", "fs", "--rules", rule, "--feature", "max_items"); err != nil { - t.Fatalf("segment create --feature: %v", err) - } - if f.lastSegmentBody["feature"] != float64(2) { - t.Errorf("feature = %v, want 2 (max_items)", f.lastSegmentBody["feature"]) - } - }) - - t.Run("rejects a too-deep tree", func(t *testing.T) { - flagUpdateEnv(t) - deep := `{"type":"ALL","rules":[{"type":"ANY","rules":[{"type":"ALL","conditions":[]}]}]}` - _, err := run("", "segment", "create", "deep", "--rules", deep) - var ue *usageError - if !errors.As(err, &ue) || !strings.Contains(err.Error(), "two levels") { - t.Errorf("err = %v, want a depth usage error", err) - } - }) -} - -func TestSegmentUpdate(t *testing.T) { - t.Run("nothing to update errors without touching the segment", func(t *testing.T) { - f := flagUpdateEnv(t) - _, err := run("", "segment", "update", "us-adults") - var ue *usageError - if !errors.As(err, &ue) || !strings.Contains(err.Error(), "nothing to update") { - t.Errorf("err = %v, want a usage error", err) - } - if f.lastSegmentBody != nil { - t.Errorf("segment was PUT despite no changes: %+v", f.lastSegmentBody) - } - }) - - t.Run("keeps rules when only description changes", func(t *testing.T) { - f := flagUpdateEnv(t) - if _, err := run("", "segment", "update", "us-adults", "--description", "new desc"); err != nil { - t.Fatalf("segment update: %v", err) - } - if f.lastSegmentBody["description"] != "new desc" || f.lastSegmentBody["rules"] == nil { - t.Errorf("body = %+v, want new description with rules preserved", f.lastSegmentBody) - } - }) - - t.Run("replaces the rule tree", func(t *testing.T) { - f := flagUpdateEnv(t) - rule := `{"type":"ALL","conditions":[{"property":"x","operator":"EQUAL","value":"1"}]}` - if _, err := run("", "segment", "update", "42", "--rules", rule); err != nil { - t.Fatalf("segment update --rules: %v", err) - } - top := f.lastSegmentBody["rules"].([]any)[0].(map[string]any) - cond := top["conditions"].([]any)[0].(map[string]any) - if cond["property"] != "x" { - t.Errorf("body rules = %+v, want the replacement", f.lastSegmentBody["rules"]) - } - }) -} - -func TestSegmentDelete(t *testing.T) { - f := flagUpdateEnv(t) - out, err := run("", "segment", "delete", "us-adults", "--yes") - if err != nil { - t.Fatalf("segment delete: %v", err) - } - if f.segments[42] != nil { - t.Errorf("segment 42 still present") - } - if !strings.Contains(out, "Deleted segment us-adults (42)") { - t.Errorf("output = %q", out) - } -} - -// withFeatures loads project 101 with feature-CRUD-shaped features (one -// multivariate, one archived), replacing the flag-oriented defaults. -func withFeatures(f *fakeInstance) { - f.features["101"] = []map[string]any{ - {"id": 88, "name": "checkout-v2", "type": "STANDARD", "description": "New checkout flow", - "initial_value": "green", "default_enabled": true, "is_archived": false, - "multivariate_options": []any{}}, - {"id": 91, "name": "banner-copy", "type": "MULTIVARIATE", "description": "A/B banner text", - "initial_value": "hello", "default_enabled": false, "is_archived": false, - "multivariate_options": []any{ - map[string]any{"id": 201, "type": "unicode", "string_value": "headline", "default_percentage_allocation": 30, "key": "hero"}, - map[string]any{"id": 202, "type": "unicode", "string_value": "subhead", "default_percentage_allocation": 70, "key": "sub"}, - }}, - {"id": 40, "name": "legacy-copy", "type": "STANDARD", "description": "Retired", - "initial_value": "old", "default_enabled": false, "is_archived": true, - "multivariate_options": []any{}}, - } -} - -func TestFeatureList(t *testing.T) { - t.Run("hides archived by default", func(t *testing.T) { - f := flagUpdateEnv(t) - withFeatures(f) - out, err := run("", "feature", "list") - if err != nil { - t.Fatalf("feature list: %v\noutput: %s", err, out) - } - if f.lastFeatArch != "false" { - t.Errorf("is_archived param = %q, want false", f.lastFeatArch) - } - for _, want := range []string{"NAME", "ID", "TYPE", "DEFAULT VALUE", "DESCRIPTION", "checkout-v2", "banner-copy", "multivariate", "green", "2 features"} { - if !strings.Contains(out, want) { - t.Errorf("output = %q, want %q", out, want) - } - } - if strings.Contains(out, "legacy-copy") { - t.Errorf("output = %q, should hide archived", out) - } - }) - - t.Run("truncates a long value", func(t *testing.T) { - f := flagUpdateEnv(t) - long := strings.Repeat("x", 200) - f.features["101"] = []map[string]any{{ - "id": 1, "name": "blob", "type": "STANDARD", "initial_value": long, - "is_archived": false, "multivariate_options": []any{}, - }} - out, err := run("", "feature", "list") - if err != nil { - t.Fatalf("feature list: %v", err) - } - if !strings.Contains(out, "…") || strings.Contains(out, long) { - t.Errorf("output = %q, want the long value truncated", out) - } - }) - - t.Run("--include-archived shows archived", func(t *testing.T) { - f := flagUpdateEnv(t) - withFeatures(f) - out, err := run("", "feature", "list", "--include-archived") - if err != nil { - t.Fatal(err) - } - if f.lastFeatArch != "" { - t.Errorf("is_archived param = %q, want unset", f.lastFeatArch) - } - if !strings.Contains(out, "legacy-copy") || !strings.Contains(out, "3 features") { - t.Errorf("output = %q, want legacy-copy and 3 features", out) - } - }) -} - -func TestFeatureGet(t *testing.T) { - t.Run("multivariate detail with variants", func(t *testing.T) { - f := flagUpdateEnv(t) - withFeatures(f) - out, err := run("", "feature", "get", "banner-copy") // by name - if err != nil { - t.Fatalf("feature get: %v\noutput: %s", err, out) - } - for _, want := range []string{"banner-copy (91)", "multivariate", "hello", "Variants", "headline", "30", "hero", "subhead"} { - if !strings.Contains(out, want) { - t.Errorf("output = %q, want %q", out, want) - } - } - }) - - t.Run("--json curated shape", func(t *testing.T) { - f := flagUpdateEnv(t) - withFeatures(f) - out, err := run("", "feature", "get", "91", "--json") // by id - if err != nil { - t.Fatal(err) - } - var v map[string]any - if err := json.Unmarshal([]byte(out), &v); err != nil { - t.Fatalf("parsing %q: %v", out, err) - } - if v["type"] != "multivariate" || v["default_value"] != "hello" { - t.Errorf("feature = %+v", v) - } - variants := v["variants"].([]any) - v0 := variants[0].(map[string]any) - if v0["value"] != "headline" || v0["weight"] != float64(30) || v0["key"] != "hero" { - t.Errorf("variant = %+v", v0) - } - }) -} - -// Single-feature commands narrow the features fetch server-side with the -// search filter (a contains match on the name), keeping the exact-match -// narrowing client-side. An id ref can't match a name search, so it fetches -// unfiltered. -func TestFeatureSearchNarrowsFetch(t *testing.T) { - t.Run("flag get by name sends search and matches exactly", func(t *testing.T) { - // Given - f := flagUpdateEnv(t) - f.features["101"] = []map[string]any{ - {"id": 7, "name": "checkout", "type": "STANDARD", - "num_segment_overrides": 0, "num_identity_overrides": 0, - "code_references_counts": []any{}, - "environment_feature_state": map[string]any{"enabled": true, "feature_state_value": nil}}, - {"id": 8, "name": "checkout_v2", "type": "STANDARD", - "num_segment_overrides": 0, "num_identity_overrides": 0, - "code_references_counts": []any{}, - "environment_feature_state": map[string]any{"enabled": false, "feature_state_value": nil}}, - } - - // When - out, err := run("", "flag", "get", "checkout") - - // Then - if err != nil { - t.Fatalf("flag get: %v\noutput: %s", err, out) - } - if got := f.featuresSearch(); got != "checkout" { - t.Errorf("search param = %q, want checkout", got) - } - if !strings.Contains(out, "checkout") || strings.Contains(out, "checkout_v2") { - t.Errorf("output = %q, want exactly checkout", out) - } - }) - - t.Run("an id ref sends no search", func(t *testing.T) { - // Given - f := flagUpdateEnv(t) - - // When - out, err := run("", "flag", "get", "2") - - // Then - if err != nil { - t.Fatalf("flag get: %v\noutput: %s", err, out) - } - if got := f.featuresSearch(); got != "" { - t.Errorf("search param = %q, want empty", got) - } - }) - - t.Run("feature get resolves a name via search", func(t *testing.T) { - // Given - f := flagUpdateEnv(t) - withFeatures(f) - - // When - out, err := run("", "feature", "get", "banner-copy") - - // Then - if err != nil { - t.Fatalf("feature get: %v\noutput: %s", err, out) - } - if got := f.featuresSearch(); got != "banner-copy" { - t.Errorf("search param = %q, want banner-copy", got) - } - }) - - t.Run("flag enable resolves a name via search", func(t *testing.T) { - // Given - f := flagUpdateEnv(t) - - // When - out, err := run("", "flag", "enable", "max_items", "--yes") - - // Then - if err != nil { - t.Fatalf("flag enable: %v\noutput: %s", err, out) - } - if got := f.featuresSearch(); got != "max_items" { - t.Errorf("search param = %q, want max_items", got) - } - }) -} - -// A falsey value must switch the behaviour off, not merely be "set". -func TestFalseyEnvSwitches(t *testing.T) { - t.Run("FLAGSMITH_JSON_OUTPUT=0 keeps human output", func(t *testing.T) { - // Given - f := flagUpdateEnv(t) - _ = f - t.Setenv("FLAGSMITH_JSON_OUTPUT", "0") - - // When - out, err := run("", "organisation", "list") - - // Then - if err != nil { - t.Fatalf("organisation list: %v\noutput: %s", err, out) - } - if !strings.Contains(out, "NAME") || strings.HasPrefix(strings.TrimSpace(out), "[") { - t.Errorf("output = %q, want the human table", out) - } - }) - - t.Run("FLAGSMITH_NO_INPUT=false still prompts", func(t *testing.T) { - // Given - f := flagUpdateEnv(t) - _ = f - fakeTTY(t) - t.Setenv("FLAGSMITH_NO_INPUT", "false") - - // When - out, err := run("n\n", "project", "delete", "101") - - // Then - if err != nil { - t.Fatalf("project delete: %v\noutput: %s", err, out) - } - if !strings.Contains(out, "Aborted; nothing deleted.") { - t.Errorf("output = %q, want the prompt to have run and been declined", out) - } - }) -} - -// A standard feature has no variants, and an empty list renders []. The slice is -// built in the command, so it never passes through getList's normalisation. -func TestFeatureVariantListEmptyIsArray(t *testing.T) { - // Given - f := flagUpdateEnv(t) - withFeatures(f) // checkout-v2 is STANDARD - - // When - out, err := run("", "feature", "variant", "list", "checkout-v2", "--json") - - // Then - if err != nil { - t.Fatalf("feature variant list --json: %v\noutput: %s", err, out) - } - if strings.TrimSpace(out) != "[]" { - t.Errorf("output = %q, want []", out) - } - - out, err = run("", "feature", "variant", "list", "checkout-v2", "--jq", ".[]") - if err != nil { - t.Fatalf("feature variant list --jq: %v\noutput: %s", err, out) - } - if strings.TrimSpace(out) != "" { - t.Errorf("output = %q, want nothing", out) - } -} - -func TestFeatureCreate(t *testing.T) { - t.Run("standard with a default value", func(t *testing.T) { - f := flagUpdateEnv(t) - withFeatures(f) - out, err := run("", "feature", "create", "checkout-3", "--value", "blue", "--description", "d", "--enabled") - if err != nil { - t.Fatalf("feature create: %v\noutput: %s", err, out) - } - b := f.lastFeatureBody - if b["name"] != "checkout-3" || b["initial_value"] != "blue" || b["description"] != "d" || b["default_enabled"] != true { - t.Errorf("body = %+v", b) - } - if !strings.Contains(out, "Created feature checkout-3") { - t.Errorf("output = %q", out) - } - }) - - t.Run("--default-value is an alias for --value", func(t *testing.T) { - f := flagUpdateEnv(t) - withFeatures(f) - if _, err := run("", "feature", "create", "aliased", "--default-value", "teal"); err != nil { - t.Fatalf("feature create --default-value: %v", err) - } - if f.lastFeatureBody["initial_value"] != "teal" { - t.Errorf("body = %+v, want initial_value teal", f.lastFeatureBody) - } - }) - - t.Run("multivariate with inline variants types by JSON value", func(t *testing.T) { - f := flagUpdateEnv(t) - withFeatures(f) - variants := `[{"value":"a","weight":30},{"value":42,"weight":70}]` - if _, err := run("", "feature", "create", "banner-2", "--value", "hello", "--variants", variants); err != nil { - t.Fatalf("feature create --variants: %v", err) - } - opts := f.lastFeatureBody["multivariate_options"].([]any) - o0 := opts[0].(map[string]any) - o1 := opts[1].(map[string]any) - if o0["type"] != "unicode" || o0["string_value"] != "a" || o0["default_percentage_allocation"] != float64(30) { - t.Errorf("variant 0 = %+v", o0) - } - if o1["type"] != "int" || o1["integer_value"] != float64(42) || o1["default_percentage_allocation"] != float64(70) { - t.Errorf("variant 1 = %+v", o1) - } - }) -} - -func TestFeatureUpdate(t *testing.T) { - t.Run("description", func(t *testing.T) { - f := flagUpdateEnv(t) - withFeatures(f) - if _, err := run("", "feature", "update", "checkout-v2", "--description", "redesign"); err != nil { - t.Fatalf("feature update: %v", err) - } - if f.lastFeatureBody["description"] != "redesign" { - t.Errorf("body = %+v", f.lastFeatureBody) - } - }) - - t.Run("archive and unarchive", func(t *testing.T) { - f := flagUpdateEnv(t) - withFeatures(f) - if _, err := run("", "feature", "update", "checkout-v2", "--archive"); err != nil { - t.Fatalf("archive: %v", err) - } - if f.lastFeatureBody["is_archived"] != true { - t.Errorf("archive body = %+v", f.lastFeatureBody) - } - if _, err := run("", "feature", "update", "legacy-copy", "--unarchive"); err != nil { - t.Fatalf("unarchive: %v", err) - } - if f.lastFeatureBody["is_archived"] != false { - t.Errorf("unarchive body = %+v", f.lastFeatureBody) - } - }) - - t.Run("archive and unarchive conflict", func(t *testing.T) { - f := flagUpdateEnv(t) - withFeatures(f) - _, err := run("", "feature", "update", "checkout-v2", "--archive", "--unarchive") - var ue *usageError - if !errors.As(err, &ue) { - t.Errorf("err = %v, want a usage error", err) - } - }) -} - -func TestFeatureDelete(t *testing.T) { - t.Run("--yes authorizes the delete", func(t *testing.T) { - f := flagUpdateEnv(t) - withFeatures(f) - out, err := run("", "feature", "delete", "checkout-v2", "--yes") - if err != nil { - t.Fatalf("feature delete: %v", err) - } - for _, it := range f.features["101"] { - if it["id"] == 88 { - t.Errorf("feature 88 still present") - } - } - if !strings.Contains(out, "Deleted feature checkout-v2 (88)") { - t.Errorf("output = %q", out) - } - }) - - // The core of the --no-input / --yes decoupling: a liveness switch must - // never authorize a destructive action. Without --yes, the delete refuses - // (exit 2, naming --yes) and performs no write. - assertRefused := func(t *testing.T, f *fakeInstance, err error) { - t.Helper() - var ue *usageError - if !errors.As(err, &ue) || !strings.Contains(err.Error(), "--yes") { - t.Errorf("err = %v, want a usage error (exit 2) naming --yes", err) - } - if it := f.featureByID("101", 88); it == nil { - t.Errorf("feature 88 was deleted without --yes") - } - } - - t.Run("FLAGSMITH_NO_INPUT does not authorize the delete", func(t *testing.T) { - f := flagUpdateEnv(t) - withFeatures(f) - t.Setenv("FLAGSMITH_NO_INPUT", "1") - _, err := run("", "feature", "delete", "checkout-v2") - assertRefused(t, f, err) - }) - - t.Run("--no-input does not authorize the delete", func(t *testing.T) { - f := flagUpdateEnv(t) - withFeatures(f) - _, err := run("", "feature", "delete", "checkout-v2", "--no-input") - assertRefused(t, f, err) - }) -} - -func TestFeatureVariant(t *testing.T) { - t.Run("list", func(t *testing.T) { - f := flagUpdateEnv(t) - withFeatures(f) - out, err := run("", "feature", "variant", "list", "banner-copy") - if err != nil { - t.Fatalf("variant list: %v\noutput: %s", err, out) - } - for _, want := range []string{"VALUE", "WEIGHT", "KEY", "ID", "headline", "30", "hero", "201", "subhead"} { - if !strings.Contains(out, want) { - t.Errorf("output = %q, want %q", out, want) - } - } - }) - - t.Run("add types the value and posts to mv-options", func(t *testing.T) { - f := flagUpdateEnv(t) - withFeatures(f) - out, err := run("", "feature", "variant", "add", "banner-copy", "--value", "cta", "--weight", "20", "--key", "button") - if err != nil { - t.Fatalf("variant add: %v\noutput: %s", err, out) - } - b := f.lastMVBody - if b["type"] != "unicode" || b["string_value"] != "cta" || b["default_percentage_allocation"] != float64(20) || - b["key"] != "button" || b["feature"] != float64(91) { - t.Errorf("mv body = %+v", b) - } - if !strings.Contains(out, "Added variant cta") { - t.Errorf("output = %q", out) - } - }) - - t.Run("add infers an integer value", func(t *testing.T) { - f := flagUpdateEnv(t) - withFeatures(f) - if _, err := run("", "feature", "variant", "add", "banner-copy", "--value", "42", "--weight", "10"); err != nil { - t.Fatalf("variant add: %v", err) - } - if f.lastMVBody["type"] != "int" || f.lastMVBody["integer_value"] != float64(42) { - t.Errorf("mv body = %+v", f.lastMVBody) - } - }) - - t.Run("update a variant by key", func(t *testing.T) { - f := flagUpdateEnv(t) - withFeatures(f) - if _, err := run("", "feature", "variant", "update", "banner-copy", "hero", "--weight", "40"); err != nil { - t.Fatalf("variant update: %v", err) - } - if f.lastMVBody["default_percentage_allocation"] != float64(40) { - t.Errorf("mv body = %+v", f.lastMVBody) - } - // value untouched (only weight sent) - if _, ok := f.lastMVBody["string_value"]; ok { - t.Errorf("mv body = %+v, want only the weight sent", f.lastMVBody) - } - }) - - t.Run("delete a variant by id", func(t *testing.T) { - f := flagUpdateEnv(t) - withFeatures(f) - out, err := run("", "feature", "variant", "delete", "banner-copy", "201", "--yes") - if err != nil { - t.Fatalf("variant delete: %v", err) - } - feat := f.featureByID("101", 91) - if len(feat["multivariate_options"].([]any)) != 1 { - t.Errorf("options = %+v, want variant 201 removed", feat["multivariate_options"]) - } - if !strings.Contains(out, "Deleted variant") { - t.Errorf("output = %q", out) - } - }) - - t.Run("unknown variant errors", func(t *testing.T) { - f := flagUpdateEnv(t) - withFeatures(f) - _, err := run("", "feature", "variant", "delete", "banner-copy", "nope", "--yes") - if err == nil || !strings.Contains(err.Error(), "nope") { - t.Errorf("err = %v, want a not-found error", err) - } - }) -} - -// withEnvironments loads project 101 with full environment records. -func withEnvironments(f *fakeInstance) { - f.envs["101"] = []map[string]any{ - {"id": 1, "name": "Development", "api_key": "WqXhZk8sVY3dGgTqZ9pJmN", "project": 101, "description": "Local dev"}, - {"id": 2, "name": "Production", "api_key": "K2mVsGdXhZ8kQqZ9pJmNbJ", "project": 101, "description": "Live", "use_v2_feature_versioning": true}, - } -} - -func TestEnvironment(t *testing.T) { - t.Run("list", func(t *testing.T) { - f := flagUpdateEnv(t) - withEnvironments(f) - out, err := run("", "environment", "list") - if err != nil { - t.Fatalf("environment list: %v\noutput: %s", err, out) - } - for _, want := range []string{"NAME", "KEY", "DESCRIPTION", "Development", "WqXhZk8sVY3dGgTqZ9pJmN", "Production", "2 environments"} { - if !strings.Contains(out, want) { - t.Errorf("output = %q, want %q", out, want) - } - } - }) - - t.Run("env alias", func(t *testing.T) { - f := flagUpdateEnv(t) - withEnvironments(f) - out, err := run("", "env", "list") - if err != nil || !strings.Contains(out, "Development") { - t.Errorf("env alias: (%q, %v)", out, err) - } - }) - - t.Run("get by name renders from the list row, project label from the cache", func(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - withEnvironments(f) - root := tempRepo(t) - writeConfig(t, root, `{"project": "acme-api", "environment": "WqXhZk8sVY3dGgTqZ9pJmN", "apiUrl": "`+f.srv.URL+`"}`) - setMasterKey(t, f.srv.URL) - - // When - out, err := run("", "environment", "get", "Production") - - // Then - if err != nil { - t.Fatalf("environment get: %v\noutput: %s", err, out) - } - for _, want := range []string{"Production (K2mVsGdXhZ8kQqZ9pJmNbJ)", "acme-api (101)", "Live"} { - if !strings.Contains(out, want) { - t.Errorf("output = %q, want %q", out, want) - } - } - if got := f.environmentGets(); got != 0 { - t.Errorf("environment retrieves = %d, want 0 (the list row is enough)", got) - } - if got := f.projectGets(); got != 0 { - t.Errorf("project retrieves = %d, want 0 (the label is cached)", got) - } - }) - - t.Run("get with a cold name cache degrades to the bare project id", func(t *testing.T) { - // Given - f := flagUpdateEnv(t) - withEnvironments(f) - - // When - out, err := run("", "environment", "get", "Production") - - // Then - if err != nil { - t.Fatalf("environment get: %v\noutput: %s", err, out) - } - if !strings.Contains(out, "101") || strings.Contains(out, "acme-api") { - t.Errorf("output = %q, want the bare project id without a name", out) - } - }) - - t.Run("--jq implies JSON and filters the retrieve payload", func(t *testing.T) { - // Given - f := flagUpdateEnv(t) - withEnvironments(f) - - // When - out, err := run("", "environment", "get", "Production", "--jq", ".project") - - // Then - if err != nil { - t.Fatalf("environment get --jq: %v\noutput: %s", err, out) - } - if strings.TrimSpace(out) != "101" { - t.Errorf("output = %q, want 101 (the retrieve payload's project)", out) - } - if got := f.environmentGets(); got != 1 { - t.Errorf("environment retrieves = %d, want 1 under --jq", got) - } - }) - - t.Run("--json mirrors the API fields via the retrieve payload", func(t *testing.T) { - // The retrieve serializer is richer than the list row, so machine - // output re-fetches for fidelity. - f := flagUpdateEnv(t) - withEnvironments(f) - out, err := run("", "environment", "get", "Production", "--json") - if err != nil { - t.Fatal(err) - } - var v map[string]any - if err := json.Unmarshal([]byte(out), &v); err != nil { - t.Fatalf("parsing %q: %v", out, err) - } - if v["use_v2_feature_versioning"] != true || v["api_key"] != "K2mVsGdXhZ8kQqZ9pJmNbJ" { - t.Errorf("env = %+v, want raw API fields", v) - } - if got := f.environmentGets(); got != 1 { - t.Errorf("environment retrieves = %d, want 1 for --json fidelity", got) - } - }) - - t.Run("create mints a key, project from context", func(t *testing.T) { - f := flagUpdateEnv(t) - withEnvironments(f) - out, err := run("", "environment", "create", "Staging") - if err != nil { - t.Fatalf("environment create: %v\noutput: %s", err, out) - } - if f.lastEnvBody["name"] != "Staging" || f.lastEnvBody["project"] != float64(101) { - t.Errorf("body = %+v", f.lastEnvBody) - } - if !strings.Contains(out, "Created environment Staging") { - t.Errorf("output = %q", out) - } - }) - - t.Run("update by key", func(t *testing.T) { - f := flagUpdateEnv(t) - withEnvironments(f) - if _, err := run("", "environment", "update", "Production", "--description", "prod live"); err != nil { - t.Fatalf("environment update: %v", err) - } - if f.lastEnvBody["description"] != "prod live" { - t.Errorf("body = %+v", f.lastEnvBody) - } - }) - - t.Run("delete", func(t *testing.T) { - f := flagUpdateEnv(t) - withEnvironments(f) - out, err := run("", "environment", "delete", "Development", "--yes") - if err != nil { - t.Fatalf("environment delete: %v", err) - } - if _, e := f.envByAPIKey("WqXhZk8sVY3dGgTqZ9pJmN"); e != nil { - t.Errorf("Development still present") - } - if !strings.Contains(out, "Deleted environment Development") { - t.Errorf("output = %q", out) - } - }) - - t.Run("clone", func(t *testing.T) { - f := flagUpdateEnv(t) - withEnvironments(f) - out, err := run("", "environment", "clone", "Production", "Production Copy") - if err != nil { - t.Fatalf("environment clone: %v", err) - } - if f.lastEnvBody["name"] != "Production Copy" { - t.Errorf("body = %+v", f.lastEnvBody) - } - if !strings.Contains(out, "Cloned Production into Production Copy") { - t.Errorf("output = %q", out) - } - }) -} - -func TestEnvironmentDocument(t *testing.T) { - t.Run("by name", func(t *testing.T) { - f := flagUpdateEnv(t) - withEnvironments(f) - out, err := run("", "environment", "document", "Production") - if err != nil { - t.Fatalf("environment document: %v\noutput: %s", err, out) - } - var doc map[string]any - if err := json.Unmarshal([]byte(out), &doc); err != nil { - t.Fatalf("parsing %q: %v", out, err) - } - if doc["api_key"] != "K2mVsGdXhZ8kQqZ9pJmNbJ" || len(doc["feature_states"].([]any)) != 2 { - t.Errorf("doc = %+v", doc) - } - }) - - t.Run("no argument uses the context environment", func(t *testing.T) { - f := flagUpdateEnv(t) // config environment = WqXhZk8sVY3dGgTqZ9pJmN (Development) - withEnvironments(f) - out, err := run("", "environment", "document") - if err != nil { - t.Fatalf("environment document: %v\noutput: %s", err, out) - } - var doc map[string]any - json.Unmarshal([]byte(out), &doc) - if doc["api_key"] != "WqXhZk8sVY3dGgTqZ9pJmN" { - t.Errorf("doc api_key = %v, want the context environment", doc["api_key"]) - } - }) - - t.Run("--jq filters the document", func(t *testing.T) { - f := flagUpdateEnv(t) - withEnvironments(f) - out, err := run("", "environment", "document", "Production", "--jq", ".feature_states | length") - if err != nil { - t.Fatalf("environment document --jq: %v", err) - } - if strings.TrimSpace(out) != "2" { - t.Errorf("out = %q, want 2", out) - } - }) -} - -func TestEnvironmentKey(t *testing.T) { - t.Run("list", func(t *testing.T) { - f := flagUpdateEnv(t) - withEnvironments(f) - f.serverKeys["K2mVsGdXhZ8kQqZ9pJmNbJ"] = []map[string]any{ - {"id": 14, "name": "CI key", "active": true, "key": "ser.existing", "created_at": "2026-07-01T00:00:00Z"}, - } - out, err := run("", "environment", "key", "list", "Production") - if err != nil { - t.Fatalf("key list: %v\noutput: %s", err, out) - } - for _, want := range []string{"NAME", "ID", "ACTIVE", "CI key", "14", "true"} { - if !strings.Contains(out, want) { - t.Errorf("output = %q, want %q", out, want) - } - } - }) - - t.Run("create prints the secret once", func(t *testing.T) { - f := flagUpdateEnv(t) - withEnvironments(f) - out, err := run("", "environment", "key", "create", "Production", "--name", "backend") - if err != nil { - t.Fatalf("key create: %v\noutput: %s", err, out) - } - if f.lastServerKey["name"] != "backend" { - t.Errorf("body = %+v", f.lastServerKey) - } - if !strings.Contains(out, "Created server-side key backend") || !strings.Contains(out, "ser.mintedKey000000000") { - t.Errorf("output = %q, want the confirmation and the secret", out) - } - }) - - t.Run("delete", func(t *testing.T) { - f := flagUpdateEnv(t) - withEnvironments(f) - f.serverKeys["K2mVsGdXhZ8kQqZ9pJmNbJ"] = []map[string]any{{"id": 14, "name": "CI key", "active": true}} - out, err := run("", "environment", "key", "delete", "Production", "14", "--yes") - if err != nil { - t.Fatalf("key delete: %v", err) - } - if len(f.serverKeys["K2mVsGdXhZ8kQqZ9pJmNbJ"]) != 0 { - t.Errorf("key 14 still present") - } - if !strings.Contains(out, "Deleted server-side key 14") { - t.Errorf("output = %q", out) - } - }) -} - -// evalEnv writes a config naming Development's client-side key, which doubles as -// the SDK credential. No Admin credential and no project are set: `evaluate` -// talks only to the SDK API, so every test here also proves it needs neither. -func evalEnv(t *testing.T) *fakeInstance { - t.Helper() - isolateStorage(t) - f := newFakeInstance(t) - root := tempRepo(t) - writeConfig(t, root, `{"environment": "WqXhZk8sVY3dGgTqZ9pJmN", "apiUrl": "`+f.srv.URL+`"}`) - return f -} - -// evalDoc parses an evaluate command's machine-readable output. -func evalDoc(t *testing.T, out string) map[string]any { - t.Helper() - var doc map[string]any - if err := json.Unmarshal([]byte(out), &doc); err != nil { - t.Fatalf("parsing %q: %v", out, err) - } - return doc -} - -// captureOSStderr collects what run writes to the process's stderr — where a -// library's own logger lands, bypassing the command's writers entirely. -func captureOSStderr(t *testing.T, run func()) string { - t.Helper() - r, w, err := os.Pipe() - if err != nil { - t.Fatal(err) - } - saved := os.Stderr - os.Stderr = w - // Deferred: run may not return — t.Fatal inside it would otherwise leave - // os.Stderr pointing at the pipe, swallowing every later test's output. - defer func() { os.Stderr = saved }() - run() - w.Close() - logged, err := io.ReadAll(r) - if err != nil { - t.Fatal(err) - } - r.Close() - return string(logged) -} - -func TestEvaluate(t *testing.T) { - t.Run("human table with count", func(t *testing.T) { - // Given - evalEnv(t) - - // When - out, err := run("", "evaluate") - - // Then - if err != nil { - t.Fatalf("evaluate: %v\noutput: %s", err, out) - } - for _, want := range []string{ - "FEATURE", "ENABLED", "VALUE", - "onboarding_banner", "on", "-", - "max_items", "off", "25", "2 flags", - } { - if !strings.Contains(out, want) { - t.Errorf("output = %q, want it to contain %q", out, want) - } - } - }) - - t.Run("the SDK's own logging never reaches stderr", func(t *testing.T) { - // Given - evalEnv(t) - - // When - var err error - logged := captureOSStderr(t, func() { _, err = run("", "evaluate") }) - - // Then - if err != nil { - t.Fatalf("evaluate: %v", err) - } - if logged != "" { - t.Errorf("stderr = %q, want the SDK's logger silenced", logged) - } - }) - - t.Run("the invocation deadline bounds evaluation", func(t *testing.T) { - // Given an SDK API slower than the deadline the user asked for. - f := evalEnv(t) - f.sdkDelay = 1200 * time.Millisecond - t.Setenv("FLAGSMITH_TIMEOUT", "1") - - // When - _, err := run("", "evaluate") - - // Then - if !errors.Is(err, context.DeadlineExceeded) { - t.Errorf("err = %v, want the deadline to bound the SDK's request", err) - } - }) - - t.Run("json is an EvaluationResult", func(t *testing.T) { - // Given - evalEnv(t) - - // When - out, err := run("", "evaluate", "--json") - - // Then - if err != nil { - t.Fatalf("evaluate --json: %v\noutput: %s", err, out) - } - doc := evalDoc(t, out) - if got, _ := doc["$schema"].(string); !strings.HasSuffix(got, "/sdk/evaluation-result.json") { - t.Errorf("$schema = %q, want the SDK's evaluation-result schema", got) - } - flags, _ := doc["flags"].(map[string]any) - if len(flags) != 2 { - t.Fatalf("flags = %+v, want one entry per feature, keyed by name", flags) - } - banner, _ := flags["onboarding_banner"].(map[string]any) - if banner["name"] != "onboarding_banner" || banner["enabled"] != true { - t.Errorf("flag = %+v, want the resolved state", banner) - } - if v, ok := banner["value"]; !ok || v != nil { - t.Errorf("flag = %+v, want an explicit null value — the schema requires the field", banner) - } - items, _ := flags["max_items"].(map[string]any) - if items["value"] != float64(25) || items["enabled"] != false { - t.Errorf("flag = %+v, want the resolved value", items) - } - // Omitted until the SDK API returns them: absent, never faked. - for _, absent := range []string{"reason", "variant"} { - if _, ok := items[absent]; ok { - t.Errorf("flag = %+v, want %q omitted rather than invented", items, absent) - } - } - if _, ok := doc["segments"]; ok { - t.Errorf("doc = %+v, want segments omitted rather than invented", doc) - } - }) - - t.Run("--js is the frontend SDK's hydration state", func(t *testing.T) { - // Given - f := evalEnv(t) - f.sdkEnvFlags["WqXhZk8sVY3dGgTqZ9pJmN"] = []map[string]any{ - {"enabled": true, "feature_state_value": "Welcome!", - "feature": map[string]any{"id": 1, "name": "banner-text"}}, - } - - // When - out, err := run("", "evaluate", "--js") - - // Then - if err != nil { - t.Fatalf("evaluate --js: %v\noutput: %s", err, out) - } - doc := evalDoc(t, out) - api, _ := doc["api"].(string) - if !strings.HasSuffix(api, "/api/v1/") { - t.Errorf("api = %q, want the SDK API base with its trailing slash", api) - } - flags, _ := doc["flags"].(map[string]any) - flag, ok := flags["banner-text"].(map[string]any) - if !ok { - t.Fatalf("flags = %+v, want the feature name as the key, verbatim", flags) - } - if flag["enabled"] != true || flag["value"] != "Welcome!" { - t.Errorf("flag = %+v", flag) - } - if _, ok := flag["name"]; ok { - t.Errorf("flag = %+v, want no name — the key is the name", flag) - } - if _, ok := doc["$schema"]; ok { - t.Errorf("doc = %+v, want no $schema — this is not an EvaluationResult", doc) - } - }) - - t.Run("--js and --json are mutually exclusive", func(t *testing.T) { - // Given - evalEnv(t) - - // When - _, err := run("", "evaluate", "--js", "--json") - - // Then - var usage *usageError - if !errors.As(err, &usage) { - t.Fatalf("err = %v, want a usage error", err) - } - }) - - t.Run("--test makes a disabled flag a failure", func(t *testing.T) { - // Given - evalEnv(t) - - // When - out, errOut, err := runSplit("", "evaluate", "max_items", "--test") - - // Then - if err == nil { - t.Fatal("err = nil, want the disabled flag to fail") - } - if !strings.Contains(err.Error(), "max_items is disabled") { - t.Errorf("err = %v, want it to say the flag is disabled", err) - } - // A failure like any other: exit 1, reason on stderr - if code := reportError(evaluateCmd, err); code != 1 { - t.Errorf("exit code = %d, want 1", code) - } - if h := hintFor(err); h != "" { - t.Errorf("hint = %q, want none", h) - } - // The environment is not named - if strings.Contains(errOut, "WqXhZk8sVY3dGgTqZ9pJmN") { - t.Errorf("stderr = %q, want no environment key", errOut) - } - // The flag itself still prints. - if !strings.Contains(out, "max_items") || !strings.Contains(out, "off") { - t.Errorf("stdout = %q, want the flag printed anyway", out) - } - }) - - t.Run("--test passes an enabled flag through", func(t *testing.T) { - // Given onboarding_banner is on. - evalEnv(t) - - // When - out, err := run("", "evaluate", "onboarding_banner", "--test") - - // Then - if err != nil { - t.Fatalf("evaluate --test: %v\noutput: %s", err, out) - } - if !strings.Contains(out, "onboarding_banner") { - t.Errorf("output = %q", out) - } - }) - - t.Run("--test composes with --json", func(t *testing.T) { - // Given - evalEnv(t) - - // When - out, _, err := runSplit("", "evaluate", "max_items", "--test", "--json") - - // Then - if err == nil { - t.Fatal("err = nil, want the disabled flag to fail") - } - if flag := evalDoc(t, out); flag["name"] != "max_items" || flag["enabled"] != false { - t.Errorf("flag = %+v, want the entry printed as well", flag) - } - }) - - t.Run("--test needs a named feature", func(t *testing.T) { - // Given - evalEnv(t) - - // When - _, err := run("", "evaluate", "--test") - - // Then - var usage *usageError - if !errors.As(err, &usage) { - t.Fatalf("err = %v, want a usage error", err) - } - if !strings.Contains(err.Error(), "--test") { - t.Errorf("err = %v, want it to name the flag", err) - } - }) - - t.Run("--js refuses a named feature", func(t *testing.T) { - // Given - evalEnv(t) - - // When - _, err := run("", "evaluate", "max_items", "--js") - - // Then - var usage *usageError - if !errors.As(err, &usage) { - t.Fatalf("err = %v, want a usage error — a one-flag state is a dud", err) - } - }) - - t.Run("--jq filters whichever shape was asked for", func(t *testing.T) { - // Given - evalEnv(t) - - // When - jsOut, err := run("", "evaluate", "--js", "--jq", ".flags.max_items.value") - if err != nil { - t.Fatalf("evaluate --js --jq: %v", err) - } - jsonOut, err := run("", "evaluate", "--jq", ".flags.max_items.name") - if err != nil { - t.Fatalf("evaluate --jq: %v", err) - } - - // Then - if strings.TrimSpace(jsOut) != "25" { - t.Errorf("--js --jq = %q, want 25", jsOut) - } - if strings.TrimSpace(jsonOut) != "max_items" { - t.Errorf("--jq = %q, want max_items", jsonOut) - } - }) - - t.Run("--js warns rather than ship a state nothing can hydrate", func(t *testing.T) { - // Given - f := evalEnv(t) - f.sdkEnvFlags["WqXhZk8sVY3dGgTqZ9pJmN"] = []map[string]any{} - - // When - out, errOut, err := runSplit("", "evaluate", "--js") - - // Then - if err != nil { - t.Fatalf("evaluate --js: %v", err) - } - if !strings.Contains(errOut, "Warning:") { - t.Errorf("stderr = %q, want a warning that the state is empty", errOut) - } - flags, _ := evalDoc(t, out)["flags"].(map[string]any) - if len(flags) != 0 { - t.Errorf("flags = %+v, want an empty map", flags) - } - }) - - t.Run("eval is an accepted alias", func(t *testing.T) { - // Given - evalEnv(t) - - // When - out, err := run("", "eval") - - // Then - if err != nil { - t.Fatalf("eval: %v\noutput: %s", err, out) - } - if !strings.Contains(out, "2 flags") { - t.Errorf("output = %q", out) - } - }) - - t.Run("a single feature renders a detail view", func(t *testing.T) { - // Given - evalEnv(t) - - // When - out, err := run("", "evaluate", "max_items") - - // Then - if err != nil { - t.Fatalf("evaluate max_items: %v\noutput: %s", err, out) - } - for _, want := range []string{"Feature", "max_items", "Enabled", "off", "Value", "25"} { - if !strings.Contains(out, want) { - t.Errorf("output = %q, want %q", out, want) - } - } - if strings.Contains(out, "onboarding_banner") { - t.Errorf("output = %q, want only the named feature", out) - } - }) - - t.Run("an unknown feature errors with a hint", func(t *testing.T) { - // Given - evalEnv(t) - - // When - _, err := run("", "evaluate", "nope") - - // Then - if err == nil { - t.Fatal("err = nil, want an error") - } - if !strings.Contains(err.Error(), `"nope"`) { - t.Errorf("err = %v, want it to name the feature", err) - } - if !strings.Contains(hintFor(err), "flagsmith eval") { - t.Errorf("hint = %q, want it to point at the listing", hintFor(err)) - } - }) - - t.Run("no flags", func(t *testing.T) { - // Given - f := evalEnv(t) - f.sdkEnvFlags["WqXhZk8sVY3dGgTqZ9pJmN"] = []map[string]any{} - - // When - out, err := run("", "evaluate") - - // Then - if err != nil { - t.Fatalf("evaluate: %v\noutput: %s", err, out) - } - if !strings.Contains(out, "No flags.") { - t.Errorf("output = %q", out) - } - }) - - t.Run("without an environment key it names the way in", func(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - root := tempRepo(t) - writeConfig(t, root, `{"apiUrl": "`+f.srv.URL+`"}`) - - // When - _, err := run("", "evaluate") - - // Then - if err == nil { - t.Fatal("err = nil, want an error") - } - if !strings.Contains(hintFor(err), "FLAGSMITH_ENVIRONMENT_KEY") { - t.Errorf("hint = %q, want it to name the variable", hintFor(err)) - } - }) - - t.Run("FLAGSMITH_ENVIRONMENT_KEY evaluates on its own", func(t *testing.T) { - // Given a config with no environment at all — the SDK credential is the - // only environment reference, as it is in CI. - isolateStorage(t) - f := newFakeInstance(t) - root := tempRepo(t) - writeConfig(t, root, `{"apiUrl": "`+f.srv.URL+`"}`) - setEnvCred(t, envEnvironmentKey, f.srv.URL, "ser.serverSideSecret") - f.sdkEnvFlags["ser.serverSideSecret"] = sdkFlagsFrom(defaultFeatures()) - - // When - out, err := run("", "evaluate") - - // Then - if err != nil { - t.Fatalf("evaluate: %v\noutput: %s", err, out) - } - if !strings.Contains(out, "2 flags") { - t.Errorf("output = %q", out) - } - }) - - t.Run("the CLI's User-Agent replaces the SDK's", func(t *testing.T) { - // Given - f := evalEnv(t) - - // When - if _, err := run("", "evaluate"); err != nil { - t.Fatalf("evaluate: %v", err) - } - - // Then - agents := f.sdkAgents() - if len(agents) != 1 || agents[0] != version.UserAgent() { - t.Errorf("User-Agent = %q, want the CLI's %q", agents, version.UserAgent()) - } - }) - - t.Run("an unexpected status is reported without a stack of SDK prefixes", func(t *testing.T) { - // Given - f := evalEnv(t) - f.sdkStatus = http.StatusInternalServerError - - // When - _, err := run("", "evaluate") - - // Then - if err == nil { - t.Fatal("err = nil, want an error") - } - if strings.Contains(err.Error(), "flagsmith:") { - t.Errorf("err = %v, want the SDK's own wording dropped", err) - } - if !strings.Contains(err.Error(), "500") { - t.Errorf("err = %v, want it to carry the status", err) - } - }) -} - -// The environment context is a client-side key or a name, as it is everywhere -// else. Evaluation needs the key, and a name only costs an Admin lookup when the -// local name cache cannot answer — so the common case stays credential-free. -func TestEvaluateResolvesEnvironmentName(t *testing.T) { - const productionKey = "K2mVsGdXhZ8kQqZ9pJmNbJ" - - // nameEnv points the context at an environment by name, with no Admin - // credential; production is the only environment the SDK API answers for. - nameEnv := func(t *testing.T, ref string) *fakeInstance { - t.Helper() - isolateStorage(t) - f := newFakeInstance(t) - root := tempRepo(t) - writeConfig(t, root, `{"project": 101, "environment": "`+ref+`", "apiUrl": "`+f.srv.URL+`"}`) - delete(f.sdkEnvFlags, "WqXhZk8sVY3dGgTqZ9pJmN") - f.sdkEnvFlags[productionKey] = sdkFlagsFrom(defaultFeatures()) - return f - } - - t.Run("a cached name resolves without an Admin credential", func(t *testing.T) { - // Given - f := nameEnv(t, "Production") - cache.Merge(f.srv.URL, &cache.Names{Environments: map[string]string{productionKey: "Production"}}) - - // When - out, err := run("", "evaluate") - - // Then - if err != nil { - t.Fatalf("evaluate: %v\noutput: %s", err, out) - } - if got := f.sdkSentKeys(); len(got) != 1 || got[0] != productionKey { - t.Errorf("X-Environment-Key = %q, want the named environment's key", got) - } - if got := f.environmentLists(); got != 0 { - t.Errorf("environment list calls = %d, want the cache to answer for free", got) - } - }) - - t.Run("-e takes a name too", func(t *testing.T) { - // Given - f := nameEnv(t, "WqXhZk8sVY3dGgTqZ9pJmN") - cache.Merge(f.srv.URL, &cache.Names{Environments: map[string]string{productionKey: "Production"}}) - - // When - out, err := run("", "evaluate", "-e", "Production") - - // Then - if err != nil { - t.Fatalf("evaluate -e: %v\noutput: %s", err, out) - } - if got := f.sdkSentKeys(); len(got) != 1 || got[0] != productionKey { - t.Errorf("X-Environment-Key = %q, want the named environment's key", got) - } - }) - - t.Run("an uncached name costs one Admin lookup, and is remembered", func(t *testing.T) { - // Given - f := nameEnv(t, "Production") - setMasterKey(t, f.srv.URL) - withEnvironments(f) - - // When - out, err := run("", "evaluate") - - // Then - if err != nil { - t.Fatalf("evaluate: %v\noutput: %s", err, out) - } - if got := f.sdkSentKeys(); len(got) != 1 || got[0] != productionKey { - t.Errorf("X-Environment-Key = %q, want the resolved key", got) - } - if got := f.environmentLists(); got != 1 { - t.Errorf("environment list calls = %d, want exactly one", got) - } - if got := cache.Load(f.srv.URL).Environments[productionKey]; got != "Production" { - t.Errorf("cache = %q, want the name remembered for next time", got) - } - }) - - t.Run("an ambiguous cached name defers to the Admin API", func(t *testing.T) { - // Given two projects' environments sharing a name in one instance-wide - // cache. Only the Admin API can scope the name to this project. - f := nameEnv(t, "Development") - setMasterKey(t, f.srv.URL) - withEnvironments(f) - f.sdkEnvFlags["WqXhZk8sVY3dGgTqZ9pJmN"] = sdkFlagsFrom(defaultFeatures()) - cache.Merge(f.srv.URL, &cache.Names{Environments: map[string]string{ - "WqXhZk8sVY3dGgTqZ9pJmN": "Development", - "OtherProjectDevKey1234": "Development", - }}) - - // When - out, err := run("", "evaluate") - - // Then - if err != nil { - t.Fatalf("evaluate: %v\noutput: %s", err, out) - } - if got := f.sdkSentKeys(); len(got) != 1 || got[0] != "WqXhZk8sVY3dGgTqZ9pJmN" { - t.Errorf("X-Environment-Key = %q, want this project's Development", got) - } - }) - - t.Run("an uncached name without credentials says how to authenticate", func(t *testing.T) { - // Given - nameEnv(t, "Production") - - // When - _, err := run("", "evaluate") - - // Then - if !errors.Is(err, auth.ErrNotLoggedIn) { - t.Errorf("err = %v, want the login error — a name cannot be resolved without one", err) - } - }) - - t.Run("a key is never resolved, cache or no cache", func(t *testing.T) { - // Given a key the cache has never seen and no credentials at all. - f := nameEnv(t, productionKey) - - // When - out, err := run("", "evaluate") - - // Then - if err != nil { - t.Fatalf("evaluate: %v\noutput: %s", err, out) - } - if got := f.environmentLists(); got != 0 { - t.Errorf("environment list calls = %d, want a key used as-is", got) - } - }) -} - -func TestEvaluateIdentity(t *testing.T) { - // The identity's own flags, distinguishable from the environment defaults. - overridden := func() []map[string]any { - flags := sdkFlagsFrom(defaultFeatures()) - flags[1]["enabled"], flags[1]["feature_state_value"] = true, 99 - return flags - } - - t.Run("resolves for an identity, transiently by default", func(t *testing.T) { - // Given - f := evalEnv(t) - f.sdkIdentityFlags["user-123"] = overridden() - - // When - out, err := run("", "evaluate", "--identity", "user-123") - - // Then - if err != nil { - t.Fatalf("evaluate --identity: %v\noutput: %s", err, out) - } - if !strings.Contains(out, "99") { - t.Errorf("output = %q, want the identity's resolved value", out) - } - body := f.identifyBody() - if body["identifier"] != "user-123" || body["transient"] != true { - t.Errorf("body = %+v, want a transient identity", body) - } - }) - - // The flag commands name this --identifier; eval names it --identity. Both - // spellings work here, so nobody has to remember which command uses which. - for _, spelling := range []string{"--identity", "--identifier", "-i"} { - t.Run("the identity is addressable as "+spelling, func(t *testing.T) { - // Given - f := evalEnv(t) - f.sdkIdentityFlags["user-123"] = overridden() - - // When - out, err := run("", "evaluate", spelling, "user-123") - - // Then - if err != nil { - t.Fatalf("evaluate %s: %v\noutput: %s", spelling, err, out) - } - if body := f.identifyBody(); body["identifier"] != "user-123" { - t.Errorf("body = %+v, want the identity resolved", body) - } - }) - } - - t.Run("traits are typed by inference", func(t *testing.T) { - // Given - f := evalEnv(t) - - // When - out, err := run("", "evaluate", "--identity", "user-123", - "--trait", "plan=premium", "--trait", "age=42", "--trait", "score=1.5", "--trait", "beta=true") - - // Then - if err != nil { - t.Fatalf("evaluate --trait: %v\noutput: %s", err, out) - } - want := map[string]any{"plan": "premium", "age": float64(42), "score": 1.5, "beta": true} - got := map[string]any{} - for _, item := range f.identifyBody()["traits"].([]any) { - trait := item.(map[string]any) - got[trait["trait_key"].(string)] = trait["trait_value"] - } - if len(got) != len(want) { - t.Fatalf("traits = %+v, want %+v", got, want) - } - for k, v := range want { - if got[k] != v { - t.Errorf("trait %s = %#v, want %#v", k, got[k], v) - } - } - }) - - t.Run("--persist opts into persistence", func(t *testing.T) { - // Given - f := evalEnv(t) - - // When - if _, err := run("", "eval", "--identity", "user-123", "--trait", "plan=premium", "--persist"); err != nil { - t.Fatalf("evaluate --persist: %v", err) - } - - // Then - if body := f.identifyBody(); body["transient"] != false { - t.Errorf("body = %+v, want transient dropped", body) - } - }) - - t.Run("traits alone evaluate an anonymous identity", func(t *testing.T) { - // Given - f := evalEnv(t) - f.sdkIdentityFlags[""] = overridden() - - // When - out, err := run("", "evaluate", "--trait", "country=US") - - // Then - if err != nil { - t.Fatalf("evaluate --trait: %v\noutput: %s", err, out) - } - body := f.identifyBody() - if body["identifier"] != "" || body["transient"] != true { - t.Errorf("body = %+v, want an anonymous transient identity", body) - } - if !strings.Contains(out, "99") { - t.Errorf("output = %q, want the anonymous identity's flags", out) - } - }) - - t.Run("--persist without an identity is a usage error", func(t *testing.T) { - // Given - evalEnv(t) - - // When - _, err := run("", "evaluate", "--trait", "country=US", "--persist") - - // Then - var usage *usageError - if !errors.As(err, &usage) { - t.Fatalf("err = %v, want a usage error", err) - } - if !strings.Contains(err.Error(), "--identity") { - t.Errorf("err = %v, want it to name the flag", err) - } - }) - - // A trait needs a key to be a trait at all; both typos are caught here rather - // than sent for the SDK API to reject. - for _, malformed := range []string{"plan", "=premium"} { - t.Run("a malformed trait is a usage error: "+malformed, func(t *testing.T) { - // Given - evalEnv(t) - - // When - _, err := run("", "evaluate", "--trait", malformed) - - // Then - var usage *usageError - if !errors.As(err, &usage) { - t.Fatalf("err = %v, want a usage error", err) - } - if !strings.Contains(err.Error(), malformed) { - t.Errorf("err = %v, want it to quote the trait as passed", err) - } - }) - } - - // An empty value is a trait set to "", which is a what-if worth asking. - t.Run("a trait with an empty value is sent as one", func(t *testing.T) { - // Given - f := evalEnv(t) - - // When - out, err := run("", "evaluate", "--identity", "user-123", "--trait", "plan=") - - // Then - if err != nil { - t.Fatalf("evaluate --trait plan=: %v\noutput: %s", err, out) - } - traits := f.identifyBody()["traits"].([]any) - if len(traits) != 1 { - t.Fatalf("traits = %+v, want the one trait", traits) - } - trait := traits[0].(map[string]any) - if trait["trait_key"] != "plan" || trait["trait_value"] != "" { - t.Errorf("trait = %+v, want plan set to the empty string", trait) - } - }) - - t.Run("a single feature resolves for the identity", func(t *testing.T) { - // Given - f := evalEnv(t) - f.sdkIdentityFlags["user-123"] = overridden() - - // When - out, err := run("", "evaluate", "max_items", "--identity", "user-123", "--json") - - // Then - if err != nil { - t.Fatalf("evaluate max_items --identity: %v\noutput: %s", err, out) - } - // Naming a feature prints the flag on its own, with no document around it, - // so `--jq .value` reads it. - flag := evalDoc(t, out) - if flag["name"] != "max_items" || flag["enabled"] != true || flag["value"] != float64(99) { - t.Errorf("flag = %+v", flag) - } - for _, absent := range []string{"$schema", "flags"} { - if _, ok := flag[absent]; ok { - t.Errorf("flag = %+v, want no %q — one flag is not an EvaluationResult", flag, absent) - } - } - }) -} - -func TestProject(t *testing.T) { - t.Run("list shows the organisation name", func(t *testing.T) { - f := flagUpdateEnv(t) - f.orgs = []map[string]any{{"id": 3, "name": "Acme"}} - f.projects["3"] = []map[string]any{ - {"id": 101, "name": "acme-api", "organisation": 3}, - {"id": 102, "name": "acme-web", "organisation": 3}, - } - out, err := run("", "project", "list") - if err != nil { - t.Fatalf("project list: %v\noutput: %s", err, out) - } - for _, want := range []string{"NAME", "ID", "ORGANISATION", "acme-api", "101", "Acme", "2 projects"} { - if !strings.Contains(out, want) { - t.Errorf("output = %q, want %q", out, want) - } - } - }) - - t.Run("list scopes to an organisation from config", func(t *testing.T) { - // Given a directory pinned to organisation 3, and a project elsewhere. - // project get resolves within the pinned org, so list must agree. - isolateStorage(t) - f := newFakeInstance(t) - root := tempRepo(t) - writeConfig(t, root, `{"organisation": 3, "project": 101, "apiUrl": "`+f.srv.URL+`"}`) - setMasterKey(t, f.srv.URL) - f.orgs = []map[string]any{{"id": 3, "name": "Acme"}, {"id": 4, "name": "Other"}} - f.projects["3"] = []map[string]any{{"id": 101, "name": "acme-api", "organisation": 3}} - f.projects["4"] = []map[string]any{{"id": 201, "name": "other-api", "organisation": 4}} - - // When - out, err := run("", "project", "list") - - // Then - if err != nil { - t.Fatalf("project list: %v\noutput: %s", err, out) - } - if !strings.Contains(out, "acme-api") { - t.Errorf("output = %q, want the pinned organisation's project", out) - } - if strings.Contains(out, "other-api") { - t.Errorf("output = %q, want no project from outside the pinned organisation", out) - } - }) - - t.Run("get by name", func(t *testing.T) { - flagUpdateEnv(t) - out, err := run("", "project", "get", "acme-api") - if err != nil { - t.Fatalf("project get: %v", err) - } - if !strings.Contains(out, "acme-api (101)") { - t.Errorf("output = %q", out) - } - }) - - t.Run("list --organisation labels orgs from the one resolution fetch", func(t *testing.T) { - // Given - f := flagUpdateEnv(t) - f.orgs = []map[string]any{{"id": 3, "name": "Acme"}} - f.projects["3"] = []map[string]any{ - {"id": 101, "name": "acme-api", "organisation": 3}, - } - - // When - out, err := run("", "project", "list", "--organisation", "Acme") - - // Then - if err != nil { - t.Fatalf("project list --organisation: %v\noutput: %s", err, out) - } - if !strings.Contains(out, "Acme (3)") { - t.Errorf("output = %q, want the organisation labelled", out) - } - if got := f.organisationLists(); got != 1 { - t.Errorf("organisation list calls = %d, want 1", got) - } - }) - - t.Run("several projects in one organisation still label from the cache", func(t *testing.T) { - // Given - f := flagUpdateEnv(t) - f.orgs = []map[string]any{{"id": 3, "name": "Acme"}} - f.projects["3"] = []map[string]any{ - {"id": 101, "name": "acme-api", "organisation": 3}, - {"id": 102, "name": "acme-web", "organisation": 3}, - } - - // When - out, err := run("", "project", "list", "--organisation", "Acme") - - // Then - if err != nil { - t.Fatalf("project list: %v\noutput: %s", err, out) - } - if !strings.Contains(out, "Acme (3)") { - t.Errorf("output = %q, want the organisation labelled", out) - } - if got := f.organisationLists(); got != 1 { - t.Errorf("organisation list calls = %d, want 1", got) - } - }) - - t.Run("--organisation scopes name resolution", func(t *testing.T) { - // Given - f := flagUpdateEnv(t) - f.orgs = []map[string]any{{"id": 3, "name": "Acme"}, {"id": 7, "name": "Beta"}} - f.projects["7"] = []map[string]any{{"id": 701, "name": "site", "organisation": 7}} - - // When / Then - _, err := run("", "project", "get", "site", "--organisation", "Acme") - if err == nil || !strings.Contains(err.Error(), `"site" not found in organisation 3`) { - t.Errorf("err = %v, want not-found scoped to organisation 3", err) - } - - out, err := run("", "project", "get", "site", "--organisation", "Beta") - if err != nil { - t.Fatalf("project get --organisation Beta: %v\noutput: %s", err, out) - } - if !strings.Contains(out, "site (701)") { - t.Errorf("output = %q, want site (701)", out) - } - }) - - t.Run("a name still resolves across organisations without a scope", func(t *testing.T) { - // Given - f := flagUpdateEnv(t) - f.orgs = []map[string]any{{"id": 3, "name": "Acme"}, {"id": 7, "name": "Beta"}} - f.projects["7"] = []map[string]any{{"id": 701, "name": "site", "organisation": 7}} - - // When - out, err := run("", "project", "get", "site") - - // Then - if err != nil { - t.Fatalf("project get: %v\noutput: %s", err, out) - } - if !strings.Contains(out, "site (701)") { - t.Errorf("output = %q, want site (701)", out) - } - }) - - t.Run("time at a confirmation does not count against the deadline", func(t *testing.T) { - // Given - f := flagUpdateEnv(t) - _ = f - fakeTTY(t) - t.Setenv("FLAGSMITH_TIMEOUT", "1") - stdin := &delayedReader{delay: 1100 * time.Millisecond, r: strings.NewReader("y\n")} - - // When - out, err := runWithStdin(stdin, "project", "delete", "101") - - // Then - if err != nil { - t.Fatalf("project delete after a slow confirm: %v\noutput: %s", err, out) - } - if !strings.Contains(out, "Deleted project 101") { - t.Errorf("output = %q, want the delete to succeed", out) - } - }) - - t.Run("delete by id prints the id once, not duplicated", func(t *testing.T) { - // Given - f := flagUpdateEnv(t) - - // When - out, err := run("", "project", "delete", "101", "--yes") - - // Then - if err != nil { - t.Fatalf("project delete: %v\noutput: %s", err, out) - } - if !strings.Contains(out, "Deleted project 101") || strings.Contains(out, "101 (101)") { - t.Errorf("output = %q, want the bare id, not duplicated", out) - } - _ = f - }) - - t.Run("delete by name labels with the name and id", func(t *testing.T) { - // Given - f := flagUpdateEnv(t) - - // When - out, err := run("", "project", "delete", "acme-api", "--yes") - - // Then - if err != nil { - t.Fatalf("project delete: %v\noutput: %s", err, out) - } - if !strings.Contains(out, "Deleted project acme-api (101)") { - t.Errorf("output = %q, want the name and id", out) - } - _ = f - }) - - t.Run("get labels the organisation from a warm cache without a fetch", func(t *testing.T) { - // Given - f := flagUpdateEnv(t) - f.orgs = []map[string]any{{"id": 3, "name": "Acme"}} - if _, err := run("", "project", "list", "--organisation", "Acme"); err != nil { - t.Fatalf("warm-up project list: %v", err) - } - before := f.organisationLists() - - // When - out, err := run("", "project", "get", "acme-api") - - // Then - if err != nil { - t.Fatalf("project get: %v\noutput: %s", err, out) - } - if !strings.Contains(out, "Acme (3)") { - t.Errorf("output = %q, want the organisation labelled", out) - } - if got := f.organisationLists(); got != before { - t.Errorf("organisation list calls = %d, want %d (no fetch on a warm cache)", got, before) - } - }) - - t.Run("--json mirrors the API fields", func(t *testing.T) { - f := flagUpdateEnv(t) - f.projects["3"] = []map[string]any{ - {"id": 101, "name": "acme-api", "organisation": 3, "hide_disabled_flags": true}, - } - out, err := run("", "project", "get", "101", "--json") - if err != nil { - t.Fatal(err) - } - var v map[string]any - if err := json.Unmarshal([]byte(out), &v); err != nil { - t.Fatalf("parsing %q: %v", out, err) - } - if v["hide_disabled_flags"] != true { - t.Errorf("project = %+v, want the raw API fields preserved", v) - } - }) - - t.Run("create requires an organisation", func(t *testing.T) { - f := flagUpdateEnv(t) - f.orgs = []map[string]any{{"id": 3, "name": "Acme"}} - out, err := run("", "project", "create", "acme-mobile", "--organisation", "Acme") - if err != nil { - t.Fatalf("project create: %v", err) - } - if f.lastProjectBody["name"] != "acme-mobile" || f.lastProjectBody["organisation"] != float64(3) { - t.Errorf("body = %+v", f.lastProjectBody) - } - if !strings.Contains(out, "Created project acme-mobile") { - t.Errorf("output = %q", out) - } - }) - - t.Run("update settings", func(t *testing.T) { - f := flagUpdateEnv(t) - if _, err := run("", "project", "update", "acme-api", "--hide-disabled-flags"); err != nil { - t.Fatalf("project update: %v", err) - } - if f.lastProjectBody["hide_disabled_flags"] != true { - t.Errorf("body = %+v", f.lastProjectBody) - } - }) - - t.Run("delete", func(t *testing.T) { - f := flagUpdateEnv(t) - out, err := run("", "project", "delete", "acme-api", "--yes") - if err != nil { - t.Fatalf("project delete: %v", err) - } - if f.projectByID(101) != nil { - t.Errorf("project 101 still present") - } - if !strings.Contains(out, "Deleted project acme-api (101)") { - t.Errorf("output = %q", out) - } - }) -} - -func TestOrganisation(t *testing.T) { - t.Run("list", func(t *testing.T) { - f := flagUpdateEnv(t) - f.orgs = []map[string]any{{"id": 3, "name": "Acme"}, {"id": 7, "name": "Beta"}} - out, err := run("", "organisation", "list") - if err != nil { - t.Fatalf("organisation list: %v\noutput: %s", err, out) - } - for _, want := range []string{"NAME", "ID", "Acme", "3", "Beta", "7", "2 organisations"} { - if !strings.Contains(out, want) { - t.Errorf("output = %q, want %q", out, want) + go func() { done <- rootCmd.Execute() }() + + authURLPattern := regexp.MustCompile(`https?://\S+/oauth/authorize/\?\S+`) + var q url.Values + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) && q == nil { + if m := authURLPattern.FindString(out.String()); m != "" { + u, err := url.Parse(m) + if err != nil { + t.Fatal(err) } + q = u.Query() + break } - }) + time.Sleep(10 * time.Millisecond) + } + if q == nil { + t.Fatalf("login never printed an authorisation URL; output: %q", out.String()) + } + if _, err := http.Get(q.Get("redirect_uri") + "?code=c&state=" + url.QueryEscape(q.Get("state"))); err != nil { + t.Fatal(err) + } - t.Run("org alias", func(t *testing.T) { - flagUpdateEnv(t) - out, err := run("", "org", "list") - if err != nil || !strings.Contains(out, "Acme") { - t.Errorf("org alias: (%q, %v)", out, err) + // Then + select { + case err := <-done: + if err != nil { + t.Fatalf("login: %v\noutput: %s", err, out.String()) } - }) + case <-time.After(10 * time.Second): + t.Fatal("login did not return") + } + if got := out.String(); !strings.Contains(got, "✓ Logged in to "+f.srv.URL+" as kim@example.com") || + !strings.Contains(got, "keychain") { + t.Errorf("login output = %q", got) + } - t.Run("an empty list renders [] not null, and --jq can iterate it", func(t *testing.T) { - // Given - f := flagUpdateEnv(t) - f.orgs = nil + // When + statusOut, err := run("", "auth", "status", "--api", f.srv.URL) - // When / Then - out, err := run("", "organisation", "list", "--json") - if err != nil { - t.Fatalf("organisation list --json: %v\noutput: %s", err, out) - } - if strings.TrimSpace(out) != "[]" { - t.Errorf("output = %q, want []", out) + // Then + if err != nil { + t.Fatalf("auth status: %v", err) + } + for _, want := range []string{"kim@example.com", "keychain", "Acme"} { + if !strings.Contains(statusOut, want) { + t.Errorf("auth status output = %q, want it to contain %q", statusOut, want) } + } - out, err = run("", "organisation", "list", "--jq", ".[]") - if err != nil { - t.Fatalf("organisation list --jq: %v\noutput: %s", err, out) - } - if strings.TrimSpace(out) != "" { - t.Errorf("output = %q, want nothing", out) - } - }) + // When + tokenOut, err := run("", "auth", "token", "--api", f.srv.URL) - t.Run("get by name", func(t *testing.T) { - flagUpdateEnv(t) - out, err := run("", "organisation", "get", "Acme") - if err != nil { - t.Fatalf("organisation get: %v", err) - } - if !strings.Contains(out, "Acme (3)") { - t.Errorf("output = %q", out) - } - }) + // Then + if err != nil { + t.Fatalf("auth token: %v", err) + } + if strings.TrimSpace(tokenOut) != oauthAccess { + t.Errorf("auth token output = %q, want the access token", tokenOut) + } - t.Run("--json mirrors the API fields", func(t *testing.T) { - f := flagUpdateEnv(t) - f.orgs = []map[string]any{{"id": 3, "name": "Acme", "force_2fa": true, "webhook_notification_email": "x@y.com"}} - out, err := run("", "organisation", "get", "3", "--json") - if err != nil { - t.Fatal(err) - } - var v map[string]any - if err := json.Unmarshal([]byte(out), &v); err != nil { - t.Fatalf("parsing %q: %v", out, err) - } - if v["force_2fa"] != true || v["webhook_notification_email"] != "x@y.com" { - t.Errorf("org = %+v, want the raw API fields preserved", v) - } - }) + // When + logoutOut, err := run("", shapeArgs(prefix, "logout", "--api", f.srv.URL)...) - t.Run("create", func(t *testing.T) { - f := flagUpdateEnv(t) - out, err := run("", "organisation", "create", "Acme Labs", "--force-2fa") - if err != nil { - t.Fatalf("organisation create: %v", err) - } - if f.lastOrgBody["name"] != "Acme Labs" || f.lastOrgBody["force_2fa"] != true { - t.Errorf("body = %+v", f.lastOrgBody) - } - if !strings.Contains(out, "Created organisation Acme Labs") { - t.Errorf("output = %q", out) - } - }) + // Then + if err != nil { + t.Fatalf("logout: %v", err) + } + if !strings.Contains(logoutOut, "Logged out of "+f.srv.URL) { + t.Errorf("logout output = %q", logoutOut) + } + if f.revokeCount() != 1 { + t.Errorf("revocations = %d, want 1", f.revokeCount()) + } - t.Run("update", func(t *testing.T) { - f := flagUpdateEnv(t) - if _, err := run("", "organisation", "update", "Acme", "--webhook-email", "a@b.com"); err != nil { - t.Fatalf("organisation update: %v", err) - } - if f.lastOrgBody["webhook_notification_email"] != "a@b.com" { - t.Errorf("body = %+v", f.lastOrgBody) - } - }) + // When / Then + if _, err := run("", "auth", "status", "--api", f.srv.URL); !errors.Is(err, auth.ErrNotLoggedIn) { + t.Errorf("auth status after logout = %v, want ErrNotLoggedIn", err) + } - t.Run("delete", func(t *testing.T) { - f := flagUpdateEnv(t) - out, err := run("", "organisation", "delete", "Acme", "--yes") - if err != nil { - t.Fatalf("organisation delete: %v", err) - } - if f.orgByID(3) != nil { - t.Errorf("org 3 still present") - } - if !strings.Contains(out, "Deleted organisation Acme (3)") { - t.Errorf("output = %q", out) - } - }) + // When / Then + logoutOut, err = run("", shapeArgs(prefix, "logout", "--api", f.srv.URL)...) + if err != nil || !strings.Contains(logoutOut, "Not logged in") { + t.Errorf("second logout = (%q, %v), want a friendly no-op", logoutOut, err) + } } -func TestAPI(t *testing.T) { - // echoJSON runs `flagsmith api api/v1/echo/ ` and returns the - // decoded reflection of the request the fake saw. - echoJSON := func(t *testing.T, stdin string, args ...string) map[string]any { - t.Helper() - out, err := run(stdin, append([]string{"api", "api/v1/echo/"}, args...)...) - if err != nil { - t.Fatalf("api: %v\noutput: %s", err, out) - } - var e map[string]any - if err := json.Unmarshal([]byte(out), &e); err != nil { - t.Fatalf("parsing %q: %v", out, err) - } - return e +func tempRepo(t *testing.T) string { + t.Helper() + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil { + t.Fatal(err) } + t.Chdir(root) + return root +} - t.Run("GET applies the admin credential automatically", func(t *testing.T) { - flagUpdateEnv(t) - e := echoJSON(t, "") - if e["method"] != "GET" || e["authorization"] != "Api-Key "+masterKey { - t.Errorf("echo = %+v", e) - } - }) +func writeConfig(t *testing.T, dir, content string) string { + t.Helper() + path := filepath.Join(dir, "flagsmith.json") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return path +} - t.Run("--jq filters the response", func(t *testing.T) { - flagUpdateEnv(t) - out, err := run("", "api", "api/v1/organisations/", "--jq", ".results[].name") - if err != nil { - t.Fatalf("api: %v", err) - } - if strings.TrimSpace(out) != "Acme" { - t.Errorf("out = %q, want Acme", out) - } - }) +func fakeTTY(t *testing.T) { + t.Helper() + orig := stdinIsTTY + stdinIsTTY = func() bool { return true } + t.Cleanup(func() { stdinIsTTY = orig }) +} - t.Run("a field implies POST with a typed JSON body", func(t *testing.T) { - flagUpdateEnv(t) - e := echoJSON(t, "", "-F", "n=3", "-f", "s=3") - if e["method"] != "POST" || e["content_type"] != "application/json" { - t.Errorf("echo = %+v", e) - } - body, _ := e["body"].(string) - if !strings.Contains(body, `"n":3`) || !strings.Contains(body, `"s":"3"`) { - t.Errorf("body = %q, want typed n and raw s", body) - } - }) +func TestSchemaURL(t *testing.T) { + orig := version.Version + t.Cleanup(func() { version.Version = orig }) - t.Run("fields on an explicit GET become query params", func(t *testing.T) { - flagUpdateEnv(t) - e := echoJSON(t, "", "-X", "GET", "-F", "a=1") - if e["method"] != "GET" || e["query"] != "a=1" { - t.Errorf("echo = %+v", e) - } - }) + // Given + version.Version = "v1.2.3" + if got := schemaURL(); !strings.Contains(got, "/v1.2.3/schema/flagsmith.json") { + t.Errorf("schemaURL() = %q, want pinned to the release tag", got) + } - t.Run("fields join a path that already has a query string", func(t *testing.T) { - // Given - flagUpdateEnv(t) - out, err := run("", "api", "api/v1/echo/?organisation=3", "-X", "GET", "-F", "page=2") - if err != nil { - t.Fatalf("api: %v\noutput: %s", err, out) - } - var e map[string]any - if err := json.Unmarshal([]byte(out), &e); err != nil { - t.Fatalf("parsing %q: %v", out, err) - } - if e["query"] != "organisation=3&page=2" { - t.Errorf("query = %q, want organisation=3&page=2", e["query"]) - } - }) + // Given + version.Version = "dev (2f71d6f)" + if got := schemaURL(); !strings.Contains(got, "/main/schema/flagsmith.json") { + t.Errorf("schemaURL() = %q, want main for a non-release build", got) + } +} - t.Run("raw body from stdin", func(t *testing.T) { - flagUpdateEnv(t) - e := echoJSON(t, `{"x":1}`, "-X", "POST", "--input", "-") - if e["body"] != `{"x":1}` { - t.Errorf("body = %q", e["body"]) - } - }) +func TestPromptSelfGuardsWithoutTTY(t *testing.T) { + // Given + orig := stdinIsTTY + stdinIsTTY = func() bool { return false } + t.Cleanup(func() { stdinIsTTY = orig }) + yesFlag = false + initPrompts(rootCmd) - t.Run("custom header", func(t *testing.T) { - flagUpdateEnv(t) - e := echoJSON(t, "", "-H", "X-Custom: hi") - if e["custom"] != "hi" { - t.Errorf("custom = %q", e["custom"]) + // When / Then + if _, err := selectPrompt(rootCmd, "project", "Project", []string{"a"}, 0); err == nil { + t.Error("selectPrompt should refuse without a TTY") + } else { + var ue *usageError + if !errors.As(err, &ue) || !strings.Contains(err.Error(), "--project") { + t.Errorf("err = %v, want usage error naming --project", err) } - }) + } +} - t.Run("--include shows the status line", func(t *testing.T) { - flagUpdateEnv(t) - out, err := run("", "api", "api/v1/echo/", "-i") - if err != nil { - t.Fatalf("api: %v", err) - } - if !strings.Contains(out, "HTTP/1.1 200") { - t.Errorf("out = %q, want a status line", out) - } - }) +func runSplit(stdin string, args ...string) (string, string, error) { + resetFlags() + if args == nil { + args = []string{} + } + outBuf, errBuf := &bytes.Buffer{}, &bytes.Buffer{} + rootCmd.SetOut(outBuf) + rootCmd.SetErr(errBuf) + rootCmd.SetIn(strings.NewReader(stdin)) + rootCmd.SetArgs(args) + err := rootCmd.Execute() + return outBuf.String(), errBuf.String(), err +} - t.Run("non-2xx exits non-zero", func(t *testing.T) { - flagUpdateEnv(t) - _, err := run("", "api", "api/v1/nope/") - if err == nil || !strings.Contains(err.Error(), "404") { - t.Errorf("err = %v, want a 404 error", err) - } - }) +func TestLogoutRevokeWarningGoesToStderr(t *testing.T) { + // Given + isolateStorage(t) + if err := auth.Save(&auth.Credentials{ + Kind: auth.KindOAuth, APIURL: "http://127.0.0.1:1", + AccessToken: "x", RefreshToken: "y", + ExpiresAt: time.Now().Add(10 * time.Minute), + }); err != nil { + t.Fatal(err) + } - t.Run("--sdk uses the environment key, not the admin credential", func(t *testing.T) { - f := flagUpdateEnv(t) - setEnvCred(t, envEnvironmentKey, f.srv.URL, "someClientKey") - e := echoJSON(t, "", "--sdk") - if e["envkey"] != "someClientKey" || e["authorization"] != "" { - t.Errorf("echo = %+v, want the SDK key and no admin auth", e) - } - }) + // When + stdout, stderr, err := runSplit("", "logout", "--api-url", "http://127.0.0.1:1") - t.Run("--input with fields is a usage error", func(t *testing.T) { - flagUpdateEnv(t) - _, err := run("", "api", "api/v1/echo/", "--input", "-", "-F", "a=1") - var ue *usageError - if !errors.As(err, &ue) { - t.Errorf("err = %v, want a usage error", err) - } - }) + // Then + if err != nil { + t.Fatalf("logout: %v", err) + } + if stdout != "" { + t.Errorf("stdout = %q, want empty", stdout) + } + if !strings.Contains(stderr, "Logged out") { + t.Errorf("stderr = %q, want the confirmation", stderr) + } + if !strings.Contains(stderr, "Warning") { + t.Errorf("stderr = %q, want the revoke warning", stderr) + } } -func TestFlagCreateIsNudge(t *testing.T) { - // Given / When - f := flagUpdateEnv(t) - out, err := run("", "flag", "create", "brand-new") +func TestBrowserLoginWithoutTTYNeverOpensBrowser(t *testing.T) { + // Given + isolateStorage(t) + f := newFakeInstance(t) + + // When + resetFlags() + out := &syncBuffer{} + rootCmd.SetOut(out) + rootCmd.SetErr(out) + rootCmd.SetIn(strings.NewReader("")) + rootCmd.SetArgs([]string{"login", "--api-url", f.srv.URL}) + done := make(chan error, 1) + go func() { done <- rootCmd.Execute() }() + + authURLPattern := regexp.MustCompile(`https?://\S+/oauth/authorize/\?\S+`) + var q url.Values + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) && q == nil { + if m := authURLPattern.FindString(out.String()); m != "" { + u, err := url.Parse(m) + if err != nil { + t.Fatal(err) + } + q = u.Query() + break + } + time.Sleep(10 * time.Millisecond) + } + if q == nil { + t.Fatalf("no authorisation URL printed; output: %q", out.String()) + } + if _, err := http.Get(q.Get("redirect_uri") + "?code=c&state=" + url.QueryEscape(q.Get("state"))); err != nil { + t.Fatal(err) + } // Then - var ue *usageError - if !errors.As(err, &ue) || !strings.Contains(hintFor(err), "feature create brand-new") { - t.Errorf("err = %v (hint %q), want a hint nudging toward `feature create`", err, hintFor(err)) - } - // ...but no usage block: `flag create` is a hidden redirect, not a real command. - if strings.Contains(out, "Usage:") { - t.Errorf("hidden redirect should not print a usage block:\n%s", out) + select { + case err := <-done: + if err != nil { + t.Fatalf("login: %v\noutput: %s", err, out.String()) + } + case <-time.After(10 * time.Second): + t.Fatal("login did not return") } - _ = f } -func TestAuthStatusHonoursConfigAPIURL(t *testing.T) { +func TestBrowserLoginRefusesNoInput(t *testing.T) { // Given isolateStorage(t) f := newFakeInstance(t) - root := tempRepo(t) - writeConfig(t, root, `{"project": 1, "apiUrl": "`+f.srv.URL+`"}`) - setMasterKey(t, f.srv.URL) // When - out, err := run("", "auth", "status") + _, err := run("", "login", "--api-url", f.srv.URL, "--no-input") // Then - if err != nil { - t.Fatalf("auth status: %v", err) - } - if !strings.Contains(out, "Acme") { - t.Errorf("output = %q, want the config-file apiUrl to have been used", out) + if err == nil || !strings.Contains(hintFor(err), "FLAGSMITH_API_KEY") { + t.Errorf("err = %v (hint %q), want a refusal hinting at FLAGSMITH_API_KEY", err, hintFor(err)) } } -func TestAuthStatusSeedsNameCache(t *testing.T) { +// --yes is authorisation, not a liveness switch, so it must NOT block a +// browser login the way --no-input does: with --yes the login proceeds to the +// OAuth callback flow rather than refusing up front. +func TestBrowserLoginNotBlockedByYes(t *testing.T) { // Given isolateStorage(t) f := newFakeInstance(t) - setMasterKey(t, f.srv.URL) // When - if _, err := run("", "auth", "status", "--api-url", f.srv.URL); err != nil { + resetFlags() + out := &syncBuffer{} + rootCmd.SetOut(out) + rootCmd.SetErr(out) + rootCmd.SetIn(strings.NewReader("")) + rootCmd.SetArgs([]string{"login", "--api-url", f.srv.URL, "--yes"}) + done := make(chan error, 1) + go func() { done <- rootCmd.Execute() }() + + authURLPattern := regexp.MustCompile(`https?://\S+/oauth/authorize/\?\S+`) + var q url.Values + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) && q == nil { + if m := authURLPattern.FindString(out.String()); m != "" { + u, err := url.Parse(m) + if err != nil { + t.Fatal(err) + } + q = u.Query() + break + } + time.Sleep(10 * time.Millisecond) + } + if q == nil { + t.Fatalf("--yes must not refuse login; no authorisation URL printed; output: %q", out.String()) + } + if _, err := http.Get(q.Get("redirect_uri") + "?code=c&state=" + url.QueryEscape(q.Get("state"))); err != nil { t.Fatal(err) } // Then - if got := cache.Load(f.srv.URL); got.Organisations["3"] != "Acme" { - t.Errorf("cache = %+v, want the organisation name remembered", got) + select { + case err := <-done: + if err != nil { + t.Fatalf("login: %v\noutput: %s", err, out.String()) + } + case <-time.After(10 * time.Second): + t.Fatal("login did not return") } } -func TestEnvMasterKey(t *testing.T) { +func TestResolveCredentialRefreshesOnceUnderConcurrency(t *testing.T) { // Given isolateStorage(t) f := newFakeInstance(t) - setMasterKey(t, f.srv.URL) + if err := auth.Save(&auth.Credentials{ + APIURL: f.srv.URL, + AccessToken: "stale", + RefreshToken: "cmd-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + }); err != nil { + t.Fatal(err) + } + apiURL = f.srv.URL + resetCredentialCache() // When - statusOut, err := run("", "auth", "status", "--api", f.srv.URL) + const n = 20 + var wg sync.WaitGroup + results := make([]*activeCredential, n) + errs := make([]error, n) + start := make(chan struct{}) + for i := range n { + wg.Add(1) + go func(i int) { + defer wg.Done() + <-start + results[i], errs[i] = resolveCredential(context.Background()) + }(i) + } + close(start) + wg.Wait() // Then - if err != nil { - t.Fatalf("auth status: %v", err) + if got := f.refreshCount(); got != 1 { + t.Errorf("refresh POSTs = %d, want exactly 1 (herd collapsed)", got) } - for _, want := range []string{"Master API key", "Acme", "$FLAGSMITH_API_KEY"} { - if !strings.Contains(statusOut, want) { - t.Errorf("auth status output = %q, want it to contain %q", statusOut, want) + for i := range n { + if errs[i] != nil { + t.Fatalf("caller %d: %v", i, errs[i]) } + if results[i].token != oauthAccess { + t.Errorf("caller %d token = %q, want the refreshed access token", i, results[i].token) + } + } +} + +func TestFlagListSegmentFansOut(t *testing.T) { + // Given + f := flagUpdateEnv(t) + override := map[string]any{"enabled": true, "feature_state_value": "x"} + f.features["101"] = []map[string]any{ + {"id": 1, "name": "alpha", "type": "STANDARD", "segment_feature_state": override, + "environment_feature_state": map[string]any{"enabled": true, "feature_state_value": nil}}, + {"id": 2, "name": "bravo", "type": "STANDARD", "segment_feature_state": override, + "environment_feature_state": map[string]any{"enabled": true, "feature_state_value": nil}}, + {"id": 3, "name": "charlie", "type": "STANDARD", "segment_feature_state": override, + "environment_feature_state": map[string]any{"enabled": true, "feature_state_value": nil}}, + } + for id := 1; id <= 3; id++ { + withFeatureSegments(f, id, map[string]any{ + "id": 1200 + id, "segment": 12, "segment_name": "powerusers", "priority": id - 1, "environment": 1, + }) } + withFeatureSegmentDelay(f, 30*time.Millisecond) // When - tokenOut, err := run("", "auth", "token", "--api", f.srv.URL) + out, err := run("", "flag", "list", "--segment", "12") // Then if err != nil { - t.Fatalf("auth token: %v", err) + t.Fatalf("flag list --segment: %v\noutput: %s", err, out) + } + alpha, bravo, charlie := strings.Index(out, "alpha"), strings.Index(out, "bravo"), strings.Index(out, "charlie") + if alpha == -1 || bravo == -1 || charlie == -1 || alpha > bravo || bravo > charlie { + t.Errorf("output = %q, want alpha, bravo, charlie in order", out) + } + if got := f.featureSegmentsCalls(); got != 3 { + t.Errorf("feature-segments calls = %d, want 3", got) } - if strings.TrimSpace(tokenOut) != masterKey { - t.Errorf("auth token output = %q, want the master key", tokenOut) + if got := f.featureSegmentsPeak(); got < 2 { + t.Errorf("feature-segments peak concurrency = %d, want the reads fanned out (>= 2)", got) } } -// A discovered flagsmith.json can name any apiUrl, so an unscoped credential -// — which names no host — must not follow it there. The scoped form is how a -// self-hosted user opts in. +func TestProject(t *testing.T) { + t.Run("time at a confirmation does not count against the deadline", func(t *testing.T) { + // Given + f := flagUpdateEnv(t) + _ = f + fakeTTY(t) + t.Setenv("FLAGSMITH_TIMEOUT", "1") + stdin := &delayedReader{delay: 1100 * time.Millisecond, r: strings.NewReader("y\n")} + + // When + out, err := runWithStdin(stdin, "project", "delete", "101") + + // Then + if err != nil { + t.Fatalf("project delete after a slow confirm: %v\noutput: %s", err, out) + } + if !strings.Contains(out, "Deleted project 101") { + t.Errorf("output = %q, want the delete to succeed", out) + } + }) + +} + func TestUnscopedCredentialNotSentToRedirectedHost(t *testing.T) { setup := func(t *testing.T) *fakeInstance { t.Helper() @@ -7484,100 +698,6 @@ func TestSDKKeyScopesToSDKSurface(t *testing.T) { }) } -func TestEnvAccessToken(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - setEnvCred(t, envAccessToken, f.srv.URL, bearerToken) - - // When - statusOut, err := run("", "auth", "status", "--api", f.srv.URL) - - // Then - if err != nil { - t.Fatalf("auth status: %v", err) - } - for _, want := range []string{"kim@example.com", "$" + scopedEnvName(envAccessToken, f.srv.URL)} { - if !strings.Contains(statusOut, want) { - t.Errorf("auth status output = %q, want it to contain %q", statusOut, want) - } - } -} - -func TestEnvMasterKeyRejectsAccessToken(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - setEnvCred(t, envAPIKey, f.srv.URL, bearerToken) - - // When - _, err := run("", "auth", "status", "--api", f.srv.URL) - - // Then - if err == nil || !strings.Contains(hintFor(err), "FLAGSMITH_ACCESS_TOKEN") { - t.Errorf("err = %v (hint %q), want a hint pointing at FLAGSMITH_ACCESS_TOKEN", err, hintFor(err)) - } -} - -func TestEnvMasterKeyBeatsAccessToken(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - setMasterKey(t, f.srv.URL) - setEnvCred(t, envAccessToken, f.srv.URL, bearerToken) - - // When - statusOut, err := run("", "auth", "status", "--api", f.srv.URL) - - // Then - if err != nil { - t.Fatalf("auth status: %v", err) - } - if !strings.Contains(statusOut, "$FLAGSMITH_API_KEY") { - t.Errorf("auth status output = %q, want FLAGSMITH_API_KEY to win", statusOut) - } -} - -func TestEnvServerKeyRejected(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - setEnvCred(t, envAPIKey, f.srv.URL, "ser.AbCdEf1234") - - // When - _, err := run("", "auth", "status", "--api", f.srv.URL) - - // Then - if err == nil || !strings.Contains(hintFor(err), "FLAGSMITH_ENVIRONMENT_KEY") { - t.Errorf("err = %v (hint %q), want a hint pointing at FLAGSMITH_ENVIRONMENT_KEY", err, hintFor(err)) - } -} - -func TestEnvBeatsKeychain(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - if err := auth.Save(&auth.Credentials{ - Kind: auth.KindOAuth, APIURL: f.srv.URL, - AccessToken: oauthAccess, RefreshToken: "cmd-refresh", - ExpiresAt: time.Now().Add(10 * time.Minute), - }); err != nil { - t.Fatal(err) - } - setMasterKey(t, f.srv.URL) - - // When - statusOut, err := run("", "auth", "status", "--api", f.srv.URL) - - // Then - if err != nil { - t.Fatalf("auth status: %v", err) - } - if !strings.Contains(statusOut, "$FLAGSMITH_API_KEY") { - t.Errorf("auth status output = %q, want the env source to win over the keychain", statusOut) - } -} - func TestLoginFailsClosedWithoutKeychain(t *testing.T) { // Given isolateStorage(t) @@ -7598,30 +718,3 @@ func TestLoginFailsClosedWithoutKeychain(t *testing.T) { t.Errorf("output = %q — the OAuth flow started despite no keychain", out) } } - -func TestRefreshPersistsToKeychain(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - if err := auth.Save(&auth.Credentials{ - Kind: auth.KindOAuth, APIURL: f.srv.URL, - AccessToken: "stale-access", RefreshToken: "cmd-refresh", - ExpiresAt: time.Now().Add(-time.Minute), - }); err != nil { - t.Fatal(err) - } - - // When - if _, err := run("", "auth", "status", "--api-url", f.srv.URL); err != nil { - t.Fatalf("auth status: %v", err) - } - - // Then - creds, err := auth.Load(f.srv.URL) - if err != nil { - t.Fatal(err) - } - if creds.AccessToken != oauthAccess { - t.Errorf("AccessToken = %q, want the refreshed token persisted", creds.AccessToken) - } -} diff --git a/internal/cmd/fake_test.go b/internal/cmd/fake_test.go new file mode 100644 index 0000000..90e0c08 --- /dev/null +++ b/internal/cmd/fake_test.go @@ -0,0 +1,1582 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "sort" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/Flagsmith/flagsmith-cli/v2/internal/auth" +) + +// fakeInstance is a Flagsmith instance stub. +type fakeInstance struct { + srv *httptest.Server + + mu sync.Mutex + reqLog []string // every request served, in arrival order + revoked []url.Values + orgs []map[string]any + projects map[string][]map[string]any // orgID -> projects + envs map[string][]map[string]any // projectID -> environments + created []string + createdEnvs []string + features map[string][]map[string]any // projectID -> features list; nil → default + lastFeatEnv string // last ?environment= seen by /features/ + lastFeatSeg string // last ?segment= seen by /features/ + lastFeatArch string // last ?is_archived= seen by /features/ + lastFeatSearch string // last ?search= seen by /features/ + featListCalls int // count of GET /features/ list calls + fsListCalls int // count of GET /features/feature-segments/ calls + fsDelay time.Duration // artificial latency for feature-segments + fsInFlight int // feature-segments requests currently being served + fsPeak int // high-water mark of fsInFlight + segListCalls int // count of GET /segments/ list calls + stListCalls int // count of GET /features/featurestates/ calls + idLookupCalls int // count of GET /identities/ (identifier lookups) + edgeLookups int // count of GET /edge-identities/ (uuid lookups) + envGetCalls int // count of GET /environments/{key}/ (retrieve) + envListCalls int // count of GET /environments/ list calls + projGetCalls int // count of GET /projects/{id}/ (retrieve) + orgListCalls int // count of GET /organisations/ list calls + tokenPosts int // count of POST /o/token/ (refresh) calls + workflowGated bool // when true, update endpoints return 403 + segmentMissing bool // when true, delete-segment-override returns 404 + + useEdge bool // GET /projects/{id}/ use_edge_identities + coreIdentities map[string]int // identifier -> identity id + coreOverrides map[int]map[int]*fakeFS // identity id -> feature id -> state + edgeOverrides map[string]map[int]*fakeFS // identifier -> feature id -> state + nextFSID int + + segments map[int]map[string]any // segment id -> segment + nextSegmentID int + + featureSegments map[string][]map[string]any // feature id -> feature-segment rows (priority order) + featureStates map[string][]map[string]any // feature id -> admin featurestates rows + + // The SDK API surface `flagsmith evaluate` reads. sdkEnvFlags is keyed by + // environment key — an unrecognised key gets a 401, as the real SDK API + // does; sdkIdentityFlags overrides it per identifier ("" is the anonymous + // identity), so an identity evaluation can be told apart from the + // environment defaults. + sdkEnvFlags map[string][]map[string]any + sdkIdentityFlags map[string][]map[string]any + lastIdentify map[string]any // last POST /api/v1/identities/ body + sdkUserAgents []string // User-Agent of every SDK API request + sdkKeys []string // X-Environment-Key of every SDK API request + sdkStatus int // when non-zero, the SDK endpoints answer with it + sdkDelay time.Duration // artificial latency for the SDK endpoints + + nextFeatureID int + nextMVID int + nextOrgID int + serverKeys map[string][]map[string]any // env api_key -> server-side keys + nextServerKeyID int +} + +// envByAPIKey finds a stored environment by its client-side key, returning its +// project key too (caller holds the lock). +func (f *fakeInstance) envByAPIKey(key string) (string, map[string]any) { + for proj, list := range f.envs { + for _, e := range list { + if e["api_key"] == key { + return proj, e + } + } + } + return "", nil +} + +func (f *fakeInstance) orgByID(id int) map[string]any { + for _, o := range f.orgs { + if o["id"] == id { + return o + } + } + return nil +} + +// projectByID finds a stored project across all orgs (caller holds the lock). +func (f *fakeInstance) projectByID(id int) map[string]any { + for _, list := range f.projects { + for _, p := range list { + if p["id"] == id { + return p + } + } + } + return nil +} + +// featureByID finds a stored feature item by id (caller holds the lock). +func (f *fakeInstance) featureByID(project string, id int) map[string]any { + for _, it := range f.features[project] { + if it["id"] == id { + return it + } + } + return nil +} + +// fakeFS is a stored identity feature-state in the fake backend. +type fakeFS struct { + id int + enabled bool + value any +} + +func newFakeInstance(t *testing.T) *fakeInstance { + t.Helper() + f := newFake() + t.Cleanup(f.srv.Close) + return f +} + +// newFake builds the fake instance without a *testing.T, so a testscript Setup +// hook — which only has an Env — can construct one too. The caller owns the +// server's lifetime. +func newFake() *fakeInstance { + f := &fakeInstance{ + orgs: []map[string]any{{"id": 3, "name": "Acme"}}, + projects: map[string][]map[string]any{ + "3": {{"id": 101, "name": "acme-api", "organisation": 3}}, + }, + envs: map[string][]map[string]any{ + "101": { + {"id": 1, "name": "Development", "api_key": "WqXhZk8sVY3dGgTqZ9pJmN"}, + {"id": 2, "name": "Production", "api_key": "K2mVsGdXhZ8kQqZ9pJmNbJ"}, + }, + "12345": { + {"id": 3, "name": "Development", "api_key": "WqXhZk8sVY3dGgTqZ9pJmN"}, + }, + }, + features: map[string][]map[string]any{"101": defaultFeatures()}, + coreIdentities: map[string]int{"user-1": 501}, + coreOverrides: map[int]map[int]*fakeFS{}, + edgeOverrides: map[string]map[int]*fakeFS{}, + nextFSID: 9000, + segments: map[int]map[string]any{ + 42: { + "id": 42, "name": "us-adults", "description": "Users in the US aged 18+", "feature": nil, + "rules": []any{map[string]any{"type": "ALL", "rules": []any{ + map[string]any{"type": "ANY", "conditions": []any{ + map[string]any{"property": "country", "operator": "IN", "value": `["US","CA"]`}, + map[string]any{"property": "age", "operator": "GREATER_THAN_INCLUSIVE", "value": "18"}, + }}, + }}}, + }, + 57: { + "id": 57, "name": "beta-optin", "description": "Opted into the beta", "feature": nil, + "rules": []any{map[string]any{"type": "ALL", "conditions": []any{ + map[string]any{"property": "beta", "operator": "IS_SET", "value": nil}, + }}}, + }, + 58: { + "id": 58, "name": "beta-cohort", "description": "Beta cohort for checkout-v2", "feature": 2, + "rules": []any{map[string]any{"type": "ALL", "conditions": []any{ + map[string]any{"property": "beta", "operator": "IS_SET", "value": nil}, + }}}, + }, + }, + sdkEnvFlags: map[string][]map[string]any{ + "WqXhZk8sVY3dGgTqZ9pJmN": sdkFlagsFrom(defaultFeatures()), + }, + sdkIdentityFlags: map[string][]map[string]any{}, + nextSegmentID: 100, + nextFeatureID: 900, + nextMVID: 300, + nextOrgID: 20, + serverKeys: map[string][]map[string]any{}, + nextServerKeyID: 500, + } + mux := http.NewServeMux() + f.mountOAuth(mux) + f.mountAccounts(mux) + f.mountEnvironments(mux) + f.mountFlags(mux) + f.mountProjectItem(mux) + f.mountIdentities(mux) + f.mountOrganisationItem(mux) + f.mountFeatures(mux) + f.mountSegments(mux) + f.mountSDK(mux) + f.srv = httptest.NewServer(f.record(mux)) + return f +} + +// record wraps the fake's mux so every request the CLI makes is logged. The +// log is what a transcript asserts on: it makes request bodies, call counts and +// query parameters visible without a bespoke recording field per endpoint. +func (f *fakeInstance) record(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + r.Body = io.NopCloser(bytes.NewReader(body)) + line := r.Method + " " + r.URL.RequestURI() + // Which credential travelled, by kind not by value: enough to pin + // scoping ("the master key never reaches the SDK surface") without + // writing a secret into a golden file. + if cred := credKind(r); cred != "" { + line += " [" + cred + "]" + } + if len(body) > 0 { + line += " " + string(body) + } + f.mu.Lock() + f.reqLog = append(f.reqLog, line) + f.mu.Unlock() + next.ServeHTTP(w, r) + }) +} + +// credKind names the credential a request carried, without echoing it. +func credKind(r *http.Request) string { + switch { + case r.Header.Get("X-Environment-Key") == masterKey: + return "master-key-as-environment-key" // a leak, and the transcript says so + case r.Header.Get("X-Environment-Key") != "": + return "environment-key" + case r.Header.Get("Authorization") == "Api-Key "+masterKey: + return "master-key" + case strings.HasPrefix(r.Header.Get("Authorization"), "Bearer "): + return "bearer" + case r.Header.Get("Authorization") != "": + return "other-credential" + } + return "" +} + +// requests returns the logged requests in the order they arrived. +func (f *fakeInstance) requests() []string { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string(nil), f.reqLog...) +} + +// applyFlagUpdate mutates the stored features to reflect an update-flag-v2 +// body, so a re-fetch after the mutation sees the new state. Called under lock. +func (f *fakeInstance) applyFlagUpdate(body map[string]any) { + feature, _ := body["feature"].(map[string]any) + name, _ := feature["name"].(string) + def, _ := body["environment_default"].(map[string]any) + enabled, _ := def["enabled"].(bool) + val, _ := def["value"].(map[string]any) + overrides, _ := body["segment_overrides"].([]any) + for _, items := range f.features { + for _, item := range items { + if item["name"] != name { + continue + } + state, _ := item["environment_feature_state"].(map[string]any) + if state == nil { + state = map[string]any{} + item["environment_feature_state"] = state + } + state["enabled"] = enabled + state["feature_state_value"] = scalarFromWire(val) + featureKey := "" + if id, ok := item["id"].(int); ok { + featureKey = strconv.Itoa(id) + } + for _, o := range overrides { + ov, _ := o.(map[string]any) + segEnabled, _ := ov["enabled"].(bool) + segVal, _ := ov["value"].(map[string]any) + item["segment_feature_state"] = map[string]any{ + "enabled": segEnabled, "feature_state_value": scalarFromWire(segVal), + } + // A priority write moves the feature-segment row, so a re-fetch + // sees the new order. + if prio, ok := ov["priority"].(float64); ok { + for _, row := range f.featureSegments[featureKey] { + if seg, _ := row["segment"].(int); float64(seg) == ov["segment_id"] { + row["priority"] = int(prio) + } + } + } + } + sort.SliceStable(f.featureSegments[featureKey], func(a, b int) bool { + pa, _ := f.featureSegments[featureKey][a]["priority"].(int) + pb, _ := f.featureSegments[featureKey][b]["priority"].(int) + return pa < pb + }) + } + } +} + +// scalarFromWire turns an update-flag-v2 {type,value} into the bare scalar the +// features list would report. +func scalarFromWire(val map[string]any) any { + t, _ := val["type"].(string) + v, _ := val["value"].(string) + switch t { + case "integer": + n, _ := strconv.Atoi(v) + return n + case "boolean": + return v == "true" + default: + return v + } +} + +func (f *fakeInstance) revokeCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.revoked) +} + +func (f *fakeInstance) featuresCalls() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.featListCalls +} + +// featureSegmentsCalls returns how many times feature-segments was hit. +func (f *fakeInstance) featureSegmentsCalls() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.fsListCalls +} + +// featureSegmentsPeak returns the most feature-segments requests that were +// ever in flight at once. +func (f *fakeInstance) featureSegmentsPeak() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.fsPeak +} + +func (f *fakeInstance) organisationLists() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.orgListCalls +} + +// defaultFeatures is the stock project features list (with per-environment +// state embedded) returned by the fake /features/ endpoint. +func defaultFeatures() []map[string]any { + return []map[string]any{ + { + "id": 1, "name": "onboarding_banner", "type": "STANDARD", + "description": "Welcome banner", "lifecycle_stage": "live", + "num_segment_overrides": 0, "num_identity_overrides": 0, + "code_references_counts": []any{}, + "environment_feature_state": map[string]any{"enabled": true, "feature_state_value": nil}, + }, + { + "id": 2, "name": "max_items", "type": "STANDARD", + "num_segment_overrides": 1, "num_identity_overrides": 2, + "code_references_counts": []any{map[string]any{"count": 3}}, + "environment_feature_state": map[string]any{"enabled": false, "feature_state_value": 25}, + }, + } +} + +// sdkFlagsFrom renders admin feature fixtures as the SDK API's flags payload — +// the shape `flagsmith evaluate` reads. +func sdkFlagsFrom(features []map[string]any) []map[string]any { + flags := make([]map[string]any, 0, len(features)) + for _, item := range features { + state, _ := item["environment_feature_state"].(map[string]any) + flags = append(flags, map[string]any{ + "enabled": state["enabled"], + "feature_state_value": state["feature_state_value"], + "feature": map[string]any{"id": item["id"], "name": item["name"]}, + }) + } + return flags +} + +func (f *fakeInstance) sdkAgents() []string { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string(nil), f.sdkUserAgents...) +} + +func (f *fakeInstance) sdkSentKeys() []string { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string(nil), f.sdkKeys...) +} + +func (f *fakeInstance) environmentLists() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.envListCalls +} + +func (f *fakeInstance) refreshCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.tokenPosts +} + +// flagUpdateEnv writes a config bound to project 101 / Development and returns +// the fake instance with admin credentials set. +func flagUpdateEnv(t *testing.T) *fakeInstance { + t.Helper() + isolateStorage(t) + f := newFakeInstance(t) + root := tempRepo(t) + writeConfig(t, root, `{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN", "apiUrl": "`+f.srv.URL+`"}`) + setMasterKey(t, f.srv.URL) + return f +} + +func withFeatureSegments(f *fakeInstance, featureID int, rows ...map[string]any) { + f.mu.Lock() + defer f.mu.Unlock() + if f.featureSegments == nil { + f.featureSegments = map[string][]map[string]any{} + } + f.featureSegments[strconv.Itoa(featureID)] = rows +} + +// withFeatureStates registers the admin featurestates rows the fake returns +// for one feature. +func withFeatureStates(f *fakeInstance, featureID int, rows ...map[string]any) { + f.mu.Lock() + defer f.mu.Unlock() + if f.featureStates == nil { + f.featureStates = map[string][]map[string]any{} + } + f.featureStates[strconv.Itoa(featureID)] = rows +} + +// The fake serves requests concurrently (see fsPeak), so every field a handler +// reads is set through a locked setter rather than assigned directly. + +// withWorkflowGating makes the update endpoints answer 403. +func withWorkflowGating(f *fakeInstance) { + f.mu.Lock() + defer f.mu.Unlock() + f.workflowGated = true +} + +// withMissingSegmentOverride makes delete-segment-override answer 404. +func withMissingSegmentOverride(f *fakeInstance) { + f.mu.Lock() + defer f.mu.Unlock() + f.segmentMissing = true +} + +// withEdgeIdentities makes the project report use_edge_identities. +func withEdgeIdentities(f *fakeInstance) { + f.mu.Lock() + defer f.mu.Unlock() + f.useEdge = true +} + +// withFeatureSegmentDelay adds latency to feature-segments, so overlapping +// requests are observable in fsPeak. +func withFeatureSegmentDelay(f *fakeInstance, d time.Duration) { + f.mu.Lock() + defer f.mu.Unlock() + f.fsDelay = d +} + +// withSegmentOverride sets project 101's features to a single max_items +// feature carrying an environment default and, optionally, a segment override. +func withSegmentOverride(f *fakeInstance, withOverride bool) { + f.mu.Lock() + defer f.mu.Unlock() + item := map[string]any{ + "id": 2, "name": "max_items", "type": "STANDARD", + "num_segment_overrides": 1, "num_identity_overrides": 0, + "environment_feature_state": map[string]any{"enabled": false, "feature_state_value": 25}, + } + if withOverride { + item["segment_feature_state"] = map[string]any{"enabled": true, "feature_state_value": "special"} + } + f.features["101"] = []map[string]any{item} +} + +func withFeatureOverridesRows(f *fakeInstance) { + withFeatureSegments(f, 2, + map[string]any{"id": 1200, "segment": 57, "segment_name": "beta-optin", "priority": 0, "environment": 1}, + map[string]any{"id": 1201, "segment": 42, "segment_name": "us-adults", "priority": 1, "environment": 1}, + ) + str := func(s string) map[string]any { return map[string]any{"type": "unicode", "string_value": s} } + withFeatureStates(f, 2, + map[string]any{"id": 9000, "feature_segment": nil, "enabled": false, "feature_state_value": str("default")}, + map[string]any{"id": 9001, "feature_segment": 1200, "enabled": true, "feature_state_value": str("blue")}, + map[string]any{"id": 9002, "feature_segment": 1201, "enabled": false, "feature_state_value": map[string]any{"type": "int", "integer_value": 25}}, + ) +} + +// mountOAuth mounts the OAuth endpoints login and refresh go through, and the identity behind a bearer token. +func (f *fakeInstance) mountOAuth(mux *http.ServeMux) { + mux.HandleFunc("GET /.well-known/oauth-authorization-server", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]string{ + "issuer": f.srv.URL, + "authorization_endpoint": f.srv.URL + "/oauth/authorize/", + "token_endpoint": f.srv.URL + "/o/token/", + "revocation_endpoint": f.srv.URL + "/o/revoke_token/", + }) + }) + mux.HandleFunc("POST /o/token/", func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + f.tokenPosts++ + f.mu.Unlock() + json.NewEncoder(w).Encode(map[string]any{ + "access_token": oauthAccess, + "refresh_token": "cmd-refresh", + "expires_in": 900, + "scope": auth.Scope, + "token_type": "Bearer", + }) + }) + mux.HandleFunc("POST /o/revoke_token/", func(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + f.mu.Lock() + f.revoked = append(f.revoked, r.PostForm) + f.mu.Unlock() + }) + mux.HandleFunc("GET /api/v1/auth/users/me/", func(w http.ResponseWriter, r *http.Request) { + a := r.Header.Get("Authorization") + if a != "Bearer "+oauthAccess && a != "Bearer "+bearerToken { + w.WriteHeader(http.StatusUnauthorized) + return + } + json.NewEncoder(w).Encode(map[string]string{"email": "kim@example.com", "uuid": "u-1"}) + }) +} + +// authorized reports whether a request carries a credential the fake accepts: +// the master key, or either of the bearer tokens. +func authorized(r *http.Request) bool { + a := r.Header.Get("Authorization") + return a == "Api-Key "+masterKey || a == "Bearer "+oauthAccess || a == "Bearer "+bearerToken +} + +// mountAccounts mounts organisations and projects: what a credential can see, and project CRUD. +func (f *fakeInstance) mountAccounts(mux *http.ServeMux) { + mux.HandleFunc("GET /api/v1/organisations/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + f.mu.Lock() + f.orgListCalls++ + orgs := f.orgs + f.mu.Unlock() + json.NewEncoder(w).Encode(map[string]any{"count": len(orgs), "results": orgs}) + }) + mux.HandleFunc("GET /api/v1/projects/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + f.mu.Lock() + var projects []map[string]any + if org := r.URL.Query().Get("organisation"); org != "" { + projects = f.projects[org] + } else { + for _, list := range f.projects { + projects = append(projects, list...) + } + } + f.mu.Unlock() + json.NewEncoder(w).Encode(map[string]any{"count": len(projects), "results": projects}) + }) + mux.HandleFunc("POST /api/v1/projects/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + name, _ := body["name"].(string) + f.mu.Lock() + f.created = append(f.created, name) + proj := map[string]any{"id": 999, "name": name} + if org, ok := body["organisation"].(float64); ok { + proj["organisation"] = int(org) + f.projects[strconv.Itoa(int(org))] = append(f.projects[strconv.Itoa(int(org))], proj) + } + if f.envs["999"] == nil { + f.envs["999"] = []map[string]any{} // created projects start empty + } + f.mu.Unlock() + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(proj) + }) +} + +// mountEnvironments mounts environments, their server-side keys, documents and clones. +func (f *fakeInstance) mountEnvironments(mux *http.ServeMux) { + mux.HandleFunc("GET /api/v1/environments/", func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + f.envListCalls++ + f.mu.Unlock() + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + f.mu.Lock() + envs, known := f.envs[r.URL.Query().Get("project")] + f.mu.Unlock() + if !known { + w.WriteHeader(http.StatusForbidden) // no access to this project + return + } + json.NewEncoder(w).Encode(map[string]any{"count": len(envs), "results": envs}) + }) + mux.HandleFunc("POST /api/v1/environments/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + name, _ := body["name"].(string) + f.mu.Lock() + f.createdEnvs = append(f.createdEnvs, name) + env := map[string]any{"id": 42, "name": name, "api_key": "createdEnvKey00000000"} + for k, v := range body { + env[k] = v + } + env["api_key"] = "createdEnvKey00000000" + if proj, ok := body["project"].(float64); ok { + key := strconv.Itoa(int(proj)) + f.envs[key] = append(f.envs[key], env) + } + f.mu.Unlock() + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(env) + }) + mux.HandleFunc("GET /api/v1/environments/{api_key}/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + f.mu.Lock() + f.envGetCalls++ + _, env := f.envByAPIKey(r.PathValue("api_key")) + f.mu.Unlock() + if env == nil { + w.WriteHeader(http.StatusNotFound) + return + } + json.NewEncoder(w).Encode(env) + }) + mux.HandleFunc("PATCH /api/v1/environments/{api_key}/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + f.mu.Lock() + _, env := f.envByAPIKey(r.PathValue("api_key")) + if env != nil { + for k, v := range body { + env[k] = v + } + } + f.mu.Unlock() + json.NewEncoder(w).Encode(env) + }) + mux.HandleFunc("DELETE /api/v1/environments/{api_key}/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + key := r.PathValue("api_key") + f.mu.Lock() + for proj, list := range f.envs { + kept := list[:0:0] + for _, e := range list { + if e["api_key"] != key { + kept = append(kept, e) + } + } + f.envs[proj] = kept + } + f.mu.Unlock() + w.WriteHeader(http.StatusNoContent) + }) + mux.HandleFunc("GET /api/v1/environments/{api_key}/document/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + json.NewEncoder(w).Encode(map[string]any{ + "api_key": r.PathValue("api_key"), + "feature_states": []any{ + map[string]any{"feature": map[string]any{"name": "onboarding"}}, + map[string]any{"feature": map[string]any{"name": "checkout"}}, + }, + }) + }) + // Server-side SDK keys sub-resource. + mux.HandleFunc("GET /api/v1/environments/{api_key}/api-keys/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + f.mu.Lock() + keys := f.serverKeys[r.PathValue("api_key")] + f.mu.Unlock() + json.NewEncoder(w).Encode(keys) // bare array (pagination_class = None) + }) + mux.HandleFunc("POST /api/v1/environments/{api_key}/api-keys/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + env := r.PathValue("api_key") + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + f.mu.Lock() + f.nextServerKeyID++ + key := map[string]any{ + "id": f.nextServerKeyID, "name": body["name"], "active": true, + "key": "ser.mintedKey000000000", "created_at": "2026-07-16T00:00:00Z", + } + f.serverKeys[env] = append(f.serverKeys[env], key) + f.mu.Unlock() + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(key) + }) + mux.HandleFunc("DELETE /api/v1/environments/{api_key}/api-keys/{id}/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + env := r.PathValue("api_key") + id, _ := strconv.Atoi(r.PathValue("id")) + f.mu.Lock() + kept := []map[string]any{} + for _, k := range f.serverKeys[env] { + if k["id"] != id { + kept = append(kept, k) + } + } + f.serverKeys[env] = kept + f.mu.Unlock() + w.WriteHeader(http.StatusNoContent) + }) + mux.HandleFunc("POST /api/v1/environments/{api_key}/clone/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + f.mu.Lock() + proj, src := f.envByAPIKey(r.PathValue("api_key")) + name, _ := body["name"].(string) + clone := map[string]any{"id": 77, "name": name, "api_key": "clonedEnvKey000000000"} + if src != nil { + clone["project"] = src["project"] + f.envs[proj] = append(f.envs[proj], clone) + } + f.mu.Unlock() + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(clone) + }) +} + +// mountFlags mounts the flag surface: the features list an environment resolves, and the two experiment writes behind flag update and delete. +func (f *fakeInstance) mountFlags(mux *http.ServeMux) { + mux.HandleFunc("GET /api/v1/projects/{project}/features/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + f.mu.Lock() + f.lastFeatEnv = r.URL.Query().Get("environment") + f.lastFeatSeg = r.URL.Query().Get("segment") + f.lastFeatArch = r.URL.Query().Get("is_archived") + f.lastFeatSearch = r.URL.Query().Get("search") + f.featListCalls++ + items := f.features[r.PathValue("project")] + // Like the backend, search is a case-insensitive contains match on the + // name — deliberately broader than the exact match the CLI wants, so + // tests exercise the client-side narrowing. + if search := r.URL.Query().Get("search"); search != "" { + filtered := []map[string]any{} + for _, it := range items { + name, _ := it["name"].(string) + if strings.Contains(strings.ToLower(name), strings.ToLower(search)) { + filtered = append(filtered, it) + } + } + items = filtered + } + if arch := r.URL.Query().Get("is_archived"); arch != "" { + want := arch == "true" + filtered := []map[string]any{} + for _, it := range items { + a, _ := it["is_archived"].(bool) + if a == want { + filtered = append(filtered, it) + } + } + items = filtered + } + f.mu.Unlock() + json.NewEncoder(w).Encode(map[string]any{ + "count": len(items), "next": nil, "previous": nil, "results": items, + }) + }) + mux.HandleFunc("POST /api/experiments/environments/{env}/update-flag-v2/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + f.mu.Lock() + gated := f.workflowGated + f.mu.Unlock() + if gated { + w.WriteHeader(http.StatusForbidden) + return + } + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + f.mu.Lock() + f.applyFlagUpdate(body) + f.mu.Unlock() + w.WriteHeader(http.StatusNoContent) + }) + mux.HandleFunc("POST /api/experiments/environments/{env}/delete-segment-override/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + f.mu.Lock() + missing := f.segmentMissing + f.mu.Unlock() + if missing { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusNoContent) + }) +} + +// mountProjectItem mounts a single project: the retrieve that carries use_edge_identities, and its updates. +func (f *fakeInstance) mountProjectItem(mux *http.ServeMux) { + // Project retrieve — carries use_edge_identities. + mux.HandleFunc("GET /api/v1/projects/{project}/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + id, _ := strconv.Atoi(r.PathValue("project")) + f.mu.Lock() + f.projGetCalls++ + resp := map[string]any{"id": id, "name": "acme-api", "organisation": 3} + if p := f.projectByID(id); p != nil { + resp = map[string]any{} + for k, v := range p { + resp[k] = v + } + } + resp["use_edge_identities"] = f.useEdge + f.mu.Unlock() + json.NewEncoder(w).Encode(resp) + }) + mux.HandleFunc("PATCH /api/v1/projects/{project}/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + id, _ := strconv.Atoi(r.PathValue("project")) + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + f.mu.Lock() + p := f.projectByID(id) + if p != nil { + for k, v := range body { + p[k] = v + } + } + f.mu.Unlock() + json.NewEncoder(w).Encode(p) + }) + mux.HandleFunc("DELETE /api/v1/projects/{project}/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + id, _ := strconv.Atoi(r.PathValue("project")) + f.mu.Lock() + for org, list := range f.projects { + kept := list[:0:0] + for _, p := range list { + if p["id"] != id { + kept = append(kept, p) + } + } + f.projects[org] = kept + } + f.mu.Unlock() + w.WriteHeader(http.StatusNoContent) + }) +} + +// mountIdentities mounts identities and their overrides, core and edge. +func (f *fakeInstance) mountIdentities(mux *http.ServeMux) { + // Core identities: identifier lookup and create. + mux.HandleFunc("GET /api/v1/environments/{env}/identities/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + q := strings.Trim(r.URL.Query().Get("q"), `"`) + f.mu.Lock() + f.idLookupCalls++ + var results []map[string]any + if id, ok := f.coreIdentities[q]; ok { + results = append(results, map[string]any{"id": id, "identifier": q}) + } + f.mu.Unlock() + json.NewEncoder(w).Encode(map[string]any{"count": len(results), "results": results}) + }) + mux.HandleFunc("POST /api/v1/environments/{env}/identities/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + ident, _ := body["identifier"].(string) + f.mu.Lock() + id := 700 + len(f.coreIdentities) + f.coreIdentities[ident] = id + f.mu.Unlock() + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{"id": id, "identifier": ident}) + }) + // Core identity feature-states: list, create, update, delete. + mux.HandleFunc("GET /api/v1/environments/{env}/identities/{id}/featurestates/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + idID, _ := strconv.Atoi(r.PathValue("id")) + fid, _ := strconv.Atoi(r.URL.Query().Get("feature")) + f.mu.Lock() + var results []map[string]any + if fs := f.coreOverrides[idID][fid]; fs != nil { + results = append(results, map[string]any{"id": fs.id, "enabled": fs.enabled, "feature_state_value": fs.value, "feature": fid}) + } + f.mu.Unlock() + json.NewEncoder(w).Encode(map[string]any{"count": len(results), "results": results}) + }) + mux.HandleFunc("POST /api/v1/environments/{env}/identities/{id}/featurestates/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + idID, _ := strconv.Atoi(r.PathValue("id")) + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + fid := int(body["feature"].(float64)) + en, _ := body["enabled"].(bool) + f.mu.Lock() + if f.coreOverrides[idID] == nil { + f.coreOverrides[idID] = map[int]*fakeFS{} + } + f.nextFSID++ + f.coreOverrides[idID][fid] = &fakeFS{id: f.nextFSID, enabled: en, value: body["feature_state_value"]} + id := f.nextFSID + f.mu.Unlock() + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{"id": id}) + }) + mux.HandleFunc("PUT /api/v1/environments/{env}/identities/{id}/featurestates/{fsid}/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + idID, _ := strconv.Atoi(r.PathValue("id")) + fsID, _ := strconv.Atoi(r.PathValue("fsid")) + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + en, _ := body["enabled"].(bool) + f.mu.Lock() + for _, fs := range f.coreOverrides[idID] { + if fs.id == fsID { + fs.enabled = en + fs.value = body["feature_state_value"] + } + } + f.mu.Unlock() + json.NewEncoder(w).Encode(map[string]any{"id": fsID}) + }) + mux.HandleFunc("DELETE /api/v1/environments/{env}/identities/{id}/featurestates/{fsid}/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + idID, _ := strconv.Atoi(r.PathValue("id")) + fsID, _ := strconv.Atoi(r.PathValue("fsid")) + f.mu.Lock() + for fid, fs := range f.coreOverrides[idID] { + if fs.id == fsID { + delete(f.coreOverrides[idID], fid) + } + } + f.mu.Unlock() + w.WriteHeader(http.StatusNoContent) + }) + // Edge identities: uuid lookup and per-uuid feature-states (read). + mux.HandleFunc("GET /api/v1/environments/{env}/edge-identities/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + q := strings.Trim(r.URL.Query().Get("q"), `"`) + f.mu.Lock() + f.edgeLookups++ + var results []map[string]any + if _, ok := f.edgeOverrides[q]; ok { + results = append(results, map[string]any{"identity_uuid": "uuid-" + q, "identifier": q}) + } + f.mu.Unlock() + json.NewEncoder(w).Encode(map[string]any{"count": len(results), "results": results}) + }) + mux.HandleFunc("GET /api/v1/environments/{env}/edge-identities/{uuid}/edge-featurestates/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + identifier := strings.TrimPrefix(r.PathValue("uuid"), "uuid-") + fid, _ := strconv.Atoi(r.URL.Query().Get("feature")) + f.mu.Lock() + var results []map[string]any + if fs := f.edgeOverrides[identifier][fid]; fs != nil { + results = append(results, map[string]any{"enabled": fs.enabled, "feature_state_value": fs.value, "feature": fid, "featurestate_uuid": "fsu"}) + } + f.mu.Unlock() + json.NewEncoder(w).Encode(map[string]any{"count": len(results), "results": results}) + }) + // Edge identifier-based feature-states (note the double environments, no slash). + mux.HandleFunc("PUT /api/v1/environments/environments/{env}/edge-identities-featurestates", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + ident, _ := body["identifier"].(string) + fid := int(body["feature"].(float64)) + en, _ := body["enabled"].(bool) + f.mu.Lock() + if f.edgeOverrides[ident] == nil { + f.edgeOverrides[ident] = map[int]*fakeFS{} + } + f.edgeOverrides[ident][fid] = &fakeFS{enabled: en, value: body["feature_state_value"]} + f.mu.Unlock() + json.NewEncoder(w).Encode(map[string]any{"feature": fid, "enabled": en, "feature_state_value": body["feature_state_value"]}) + }) + mux.HandleFunc("DELETE /api/v1/environments/environments/{env}/edge-identities-featurestates", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + ident, _ := body["identifier"].(string) + f.mu.Lock() + if fv, ok := body["feature"].(float64); ok { + delete(f.edgeOverrides[ident], int(fv)) + } + f.mu.Unlock() + w.WriteHeader(http.StatusNoContent) + }) +} + +// mountOrganisationItem mounts a single organisation, and organisation CRUD. +func (f *fakeInstance) mountOrganisationItem(mux *http.ServeMux) { + // Organisation CRUD (the list route handles GET /organisations/). + mux.HandleFunc("GET /api/v1/organisations/{id}/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + id, _ := strconv.Atoi(r.PathValue("id")) + f.mu.Lock() + o := f.orgByID(id) + f.mu.Unlock() + if o == nil { + w.WriteHeader(http.StatusNotFound) + return + } + json.NewEncoder(w).Encode(o) + }) + mux.HandleFunc("POST /api/v1/organisations/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + f.mu.Lock() + f.nextOrgID++ + body["id"] = f.nextOrgID + f.orgs = append(f.orgs, body) + f.mu.Unlock() + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(body) + }) + mux.HandleFunc("PATCH /api/v1/organisations/{id}/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + id, _ := strconv.Atoi(r.PathValue("id")) + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + f.mu.Lock() + o := f.orgByID(id) + if o != nil { + for k, v := range body { + o[k] = v + } + } + f.mu.Unlock() + json.NewEncoder(w).Encode(o) + }) + mux.HandleFunc("DELETE /api/v1/organisations/{id}/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + id, _ := strconv.Atoi(r.PathValue("id")) + f.mu.Lock() + kept := f.orgs[:0:0] + for _, o := range f.orgs { + if o["id"] != id { + kept = append(kept, o) + } + } + f.orgs = kept + f.mu.Unlock() + w.WriteHeader(http.StatusNoContent) + }) +} + +// mountFeatures mounts features and their multivariate options. +func (f *fakeInstance) mountFeatures(mux *http.ServeMux) { + // Feature retrieve (feature CRUD; the list route is shared with flags). + mux.HandleFunc("GET /api/v1/projects/{project}/features/{id}/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + id, _ := strconv.Atoi(r.PathValue("id")) + f.mu.Lock() + var found map[string]any + for _, it := range f.features[r.PathValue("project")] { + if it["id"] == id { + found = it + break + } + } + f.mu.Unlock() + if found == nil { + w.WriteHeader(http.StatusNotFound) + return + } + json.NewEncoder(w).Encode(found) + }) + // Feature create/update/delete. + mux.HandleFunc("POST /api/v1/projects/{project}/features/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + project := r.PathValue("project") + f.mu.Lock() + f.nextFeatureID++ + body["id"] = f.nextFeatureID + f.features[project] = append(f.features[project], body) + f.mu.Unlock() + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(body) + }) + mux.HandleFunc("PATCH /api/v1/projects/{project}/features/{id}/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + id, _ := strconv.Atoi(r.PathValue("id")) + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + f.mu.Lock() + var found map[string]any + for _, it := range f.features[r.PathValue("project")] { + if it["id"] == id { + for k, v := range body { + it[k] = v + } + found = it + } + } + f.mu.Unlock() + if found == nil { + w.WriteHeader(http.StatusNotFound) + return + } + json.NewEncoder(w).Encode(found) + }) + mux.HandleFunc("DELETE /api/v1/projects/{project}/features/{id}/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + id, _ := strconv.Atoi(r.PathValue("id")) + project := r.PathValue("project") + f.mu.Lock() + kept := f.features[project][:0:0] + for _, it := range f.features[project] { + if it["id"] != id { + kept = append(kept, it) + } + } + f.features[project] = kept + f.mu.Unlock() + w.WriteHeader(http.StatusNoContent) + }) + // Multivariate options sub-resource. + mux.HandleFunc("POST /api/v1/projects/{project}/features/{feature}/mv-options/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + fid, _ := strconv.Atoi(r.PathValue("feature")) + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + f.mu.Lock() + f.nextMVID++ + body["id"] = f.nextMVID + if feat := f.featureByID(r.PathValue("project"), fid); feat != nil { + opts, _ := feat["multivariate_options"].([]any) + feat["multivariate_options"] = append(opts, body) + } + f.mu.Unlock() + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(body) + }) + mux.HandleFunc("PATCH /api/v1/projects/{project}/features/{feature}/mv-options/{id}/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + fid, _ := strconv.Atoi(r.PathValue("feature")) + oid, _ := strconv.Atoi(r.PathValue("id")) + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + f.mu.Lock() + var found map[string]any + if feat := f.featureByID(r.PathValue("project"), fid); feat != nil { + for _, o := range feat["multivariate_options"].([]any) { + om := o.(map[string]any) + if om["id"] == oid { + for k, v := range body { + om[k] = v + } + found = om + } + } + } + f.mu.Unlock() + json.NewEncoder(w).Encode(found) + }) + mux.HandleFunc("DELETE /api/v1/projects/{project}/features/{feature}/mv-options/{id}/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + fid, _ := strconv.Atoi(r.PathValue("feature")) + oid, _ := strconv.Atoi(r.PathValue("id")) + f.mu.Lock() + if feat := f.featureByID(r.PathValue("project"), fid); feat != nil { + opts, _ := feat["multivariate_options"].([]any) + kept := []any{} + for _, o := range opts { + if o.(map[string]any)["id"] != oid { + kept = append(kept, o) + } + } + feat["multivariate_options"] = kept + } + f.mu.Unlock() + w.WriteHeader(http.StatusNoContent) + }) +} + +// mountSegments mounts segments, and the override metadata behind the priority views. +func (f *fakeInstance) mountSegments(mux *http.ServeMux) { + // Segments: list, retrieve, create, update, delete. + mux.HandleFunc("GET /api/v1/projects/{project}/segments/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + include := r.URL.Query().Get("include_feature_specific") == "true" + f.mu.Lock() + f.segListCalls++ + results := []map[string]any{} + for _, s := range f.segments { + if !include && s["feature"] != nil { + continue + } + results = append(results, s) + } + f.mu.Unlock() + // By id, as a paginated API would: the segments are held in a map, and + // its iteration order would otherwise vary between runs. + sort.Slice(results, func(i, j int) bool { + return results[i]["id"].(int) < results[j]["id"].(int) + }) + json.NewEncoder(w).Encode(map[string]any{"count": len(results), "results": results}) + }) + mux.HandleFunc("POST /api/v1/projects/{project}/segments/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + f.mu.Lock() + f.nextSegmentID++ + id := f.nextSegmentID + body["id"] = id + f.segments[id] = body + f.mu.Unlock() + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(body) + }) + mux.HandleFunc("GET /api/v1/projects/{project}/segments/{id}/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + id, _ := strconv.Atoi(r.PathValue("id")) + f.mu.Lock() + s := f.segments[id] + f.mu.Unlock() + if s == nil { + w.WriteHeader(http.StatusNotFound) + return + } + json.NewEncoder(w).Encode(s) + }) + mux.HandleFunc("PUT /api/v1/projects/{project}/segments/{id}/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + id, _ := strconv.Atoi(r.PathValue("id")) + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + f.mu.Lock() + body["id"] = id + f.segments[id] = body + f.mu.Unlock() + json.NewEncoder(w).Encode(body) + }) + mux.HandleFunc("DELETE /api/v1/projects/{project}/segments/{id}/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + id, _ := strconv.Atoi(r.PathValue("id")) + f.mu.Lock() + delete(f.segments, id) + f.mu.Unlock() + w.WriteHeader(http.StatusNoContent) + }) + // feature-segments lists a feature's segment overrides (priority + segment + // name metadata), ordered by priority, for one environment. + mux.HandleFunc("GET /api/v1/features/feature-segments/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + if r.URL.Query().Get("environment") == "" || r.URL.Query().Get("feature") == "" { + w.WriteHeader(http.StatusBadRequest) // both are required upstream + return + } + f.mu.Lock() + f.fsListCalls++ + f.fsInFlight++ + if f.fsInFlight > f.fsPeak { + f.fsPeak = f.fsInFlight + } + delay := f.fsDelay + rows := f.featureSegments[r.URL.Query().Get("feature")] + f.mu.Unlock() + if delay > 0 { + time.Sleep(delay) + } + f.mu.Lock() + f.fsInFlight-- + f.mu.Unlock() + if rows == nil { + rows = []map[string]any{} + } + json.NewEncoder(w).Encode(map[string]any{"count": len(rows), "results": rows}) + }) + // featurestates lists a feature's states in one environment (the default + // plus one row per segment override), with the typed value wire form. + mux.HandleFunc("GET /api/v1/features/featurestates/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + if r.URL.Query().Get("environment") == "" { + w.WriteHeader(http.StatusBadRequest) // required upstream + return + } + f.mu.Lock() + f.stListCalls++ + rows := f.featureStates[r.URL.Query().Get("feature")] + f.mu.Unlock() + if rows == nil { + rows = []map[string]any{} + } + json.NewEncoder(w).Encode(map[string]any{"count": len(rows), "results": rows}) + }) + // The environment featurestates list with ?anyIdentity= is how core + // identity overrides for one feature are enumerated. + mux.HandleFunc("GET /api/v1/environments/{env}/featurestates/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + if r.URL.Query().Get("anyIdentity") == "" { + w.WriteHeader(http.StatusBadRequest) // the CLI only uses the identity mode + return + } + featureID, _ := strconv.Atoi(r.URL.Query().Get("feature")) + f.mu.Lock() + identifiers := make([]string, 0, len(f.coreIdentities)) + for identifier := range f.coreIdentities { + identifiers = append(identifiers, identifier) + } + sort.Strings(identifiers) + results := []map[string]any{} + for _, identifier := range identifiers { + id := f.coreIdentities[identifier] + if fs := f.coreOverrides[id][featureID]; fs != nil { + results = append(results, map[string]any{ + "id": fs.id, "enabled": fs.enabled, "feature_state_value": fs.value, + "identity": map[string]any{"id": id, "identifier": identifier}, + "feature": featureID, + }) + } + } + f.mu.Unlock() + json.NewEncoder(w).Encode(map[string]any{"count": len(results), "results": results}) + }) + // Edge identity overrides: no trailing slash, no pagination. + mux.HandleFunc("GET /api/v1/environments/{env}/edge-identity-overrides", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + featureID, _ := strconv.Atoi(r.URL.Query().Get("feature")) + f.mu.Lock() + identifiers := make([]string, 0, len(f.edgeOverrides)) + for identifier := range f.edgeOverrides { + identifiers = append(identifiers, identifier) + } + sort.Strings(identifiers) + results := []map[string]any{} + for _, identifier := range identifiers { + if fs := f.edgeOverrides[identifier][featureID]; fs != nil { + results = append(results, map[string]any{ + "identifier": identifier, + "feature_state": map[string]any{ + "enabled": fs.enabled, "feature_state_value": fs.value, "feature": featureID, + }, + }) + } + } + f.mu.Unlock() + json.NewEncoder(w).Encode(map[string]any{"results": results}) + }) +} + +// mountSDK mounts the SDK API: the two endpoints the Flagsmith SDK evaluates flags over, and the echo used to exercise `flagsmith api`. +func (f *fakeInstance) mountSDK(mux *http.ServeMux) { + // The SDK API: the two endpoints the Flagsmith SDK evaluates flags over. + mux.HandleFunc("GET /api/v1/flags/", func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + f.sdkUserAgents = append(f.sdkUserAgents, r.Header.Get("User-Agent")) + f.sdkKeys = append(f.sdkKeys, r.Header.Get("X-Environment-Key")) + status, flags, delay := f.sdkStatus, f.sdkEnvFlags[r.Header.Get("X-Environment-Key")], f.sdkDelay + f.mu.Unlock() + time.Sleep(delay) + if status != 0 { + w.WriteHeader(status) + return + } + if flags == nil { + w.WriteHeader(http.StatusUnauthorized) + return + } + json.NewEncoder(w).Encode(flags) + }) + mux.HandleFunc("POST /api/v1/identities/", func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + identifier, _ := body["identifier"].(string) + f.mu.Lock() + f.sdkUserAgents = append(f.sdkUserAgents, r.Header.Get("User-Agent")) + f.sdkKeys = append(f.sdkKeys, r.Header.Get("X-Environment-Key")) + f.lastIdentify = body + status, flags, delay := f.sdkStatus, f.sdkEnvFlags[r.Header.Get("X-Environment-Key")], f.sdkDelay + if override, ok := f.sdkIdentityFlags[identifier]; ok { + flags = override + } + f.mu.Unlock() + time.Sleep(delay) + if status != 0 { + w.WriteHeader(status) + return + } + if flags == nil { + w.WriteHeader(http.StatusUnauthorized) + return + } + json.NewEncoder(w).Encode(map[string]any{ + "identifier": identifier, "traits": body["traits"], "flags": flags, + }) + }) + // echo reflects the request back, for exercising `flagsmith api`. + mux.HandleFunc("/api/v1/echo/", func(w http.ResponseWriter, r *http.Request) { + if !authorized(r) && r.Header.Get("X-Environment-Key") == "" { + w.WriteHeader(http.StatusUnauthorized) + return + } + body, _ := io.ReadAll(r.Body) + json.NewEncoder(w).Encode(map[string]any{ + "method": r.Method, + "path": r.URL.Path, + "query": r.URL.RawQuery, + "authorization": r.Header.Get("Authorization"), + "envkey": r.Header.Get("X-Environment-Key"), + "content_type": r.Header.Get("Content-Type"), + "custom": r.Header.Get("X-Custom"), + "body": string(body), + }) + }) +} diff --git a/internal/cmd/prompts.go b/internal/cmd/prompts.go index 4a7631f..3d20ad9 100644 --- a/internal/cmd/prompts.go +++ b/internal/cmd/prompts.go @@ -16,10 +16,10 @@ var stdinIsTTY = func() bool { return term.IsTerminal(int(os.Stdin.Fd())) } -// rawTerminal gates the arrow-key selector, which puts the real stdin in -// raw mode — never stubbed, so tests exercise the line-based fallback. +// rawTerminal gates the arrow-key selector, which puts the real stdin in raw +// mode and draws over stderr. var rawTerminal = func() bool { - return term.IsTerminal(int(os.Stdin.Fd())) + return term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stderr.Fd())) } // noInput reports the non-interactive switch: --no-input or FLAGSMITH_NO_INPUT. diff --git a/internal/cmd/script_test.go b/internal/cmd/script_test.go new file mode 100644 index 0000000..5b4ddcf --- /dev/null +++ b/internal/cmd/script_test.go @@ -0,0 +1,628 @@ +package cmd + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "regexp" + "runtime" + "slices" + "sort" + "strconv" + "strings" + "testing" + "time" + + "github.com/Flagsmith/flagsmith-cli/v2/internal/auth" + "github.com/Flagsmith/flagsmith-cli/v2/internal/cache" + "github.com/rogpeppe/go-internal/testscript" + "github.com/zalando/go-keyring" +) + +// PROTOTYPE 2 — the same transcripts as testscript scripts. +// +// Each .txtar under testdata/script is one scenario: the files it starts with, +// the commands it runs, and the output it expects, in one file. The CLI runs as +// a real subprocess, so exit codes, os.Stderr and process-global state are the +// real thing rather than something the harness simulates. +// +// go test ./internal/cmd -run TestScripts -update-scripts + +var updateScripts = flag.Bool("update-scripts", false, "rewrite expected output in testdata/script") + +// TestMain lets the test binary impersonate the CLI, so `flagsmith` inside a +// script is a real process running the real main. Main always calls os.Exit. +func TestMain(m *testing.M) { + testscript.Main(m, map[string]func(){ + "flagsmith": func() { + // Mocked here, inside the impersonated binary: this is a fresh + // process, so without it the CLI would read and write the + // developer's real OS keychain. + keyring.MockInit() + + // The mock lives only as long as this process, so a script's + // keychain travels between invocations as a file. Execute returns + // on success and exits on failure, so what a failed invocation + // wrote is not carried forward. + loadScriptKeychain() + Execute() + saveScriptKeychain() + }, + }) +} + +// $KEYCHAIN names the file a script's keychain lives in. A script seeds it as +// an ordinary txtar file, and reads it back to see what a command stored: +// +// -- keychain.json -- +// [{"api_url": "$API", "kind": "oauth", "access_token": "stale"}] +const keychainEnv = "KEYCHAIN" + +// scriptKeychainURLs are the instances whose credentials travel between +// invocations: the fake, plus whatever a script seeded. The keychain is keyed +// by instance URL and offers no way to enumerate, so the set is tracked here. +var scriptKeychainURLs []string + +// loadScriptKeychain puts the credentials a script seeded into the mock. +func loadScriptKeychain() { + if api := os.Getenv("API"); api != "" { + scriptKeychainURLs = append(scriptKeychainURLs, api) + } + raw, err := os.ReadFile(os.Getenv(keychainEnv)) + if err != nil { + return + } + // txtar file bodies are not environment-substituted, so a seed names the + // fake as $API and it is expanded here. + raw = []byte(strings.ReplaceAll(string(raw), "$API", os.Getenv("API"))) + var creds []auth.Credentials + if err := json.Unmarshal(raw, &creds); err != nil { + fmt.Fprintln(os.Stderr, "seeding the keychain:", err) + os.Exit(1) + } + for _, c := range creds { + if err := auth.Save(&c); err != nil { + fmt.Fprintln(os.Stderr, "seeding the keychain:", err) + os.Exit(1) + } + scriptKeychainURLs = append(scriptKeychainURLs, c.APIURL) + } +} + +// saveScriptKeychain writes back what the invocation left in the keychain, for +// the next invocation and for the script to assert on. +func saveScriptKeychain() { + path := os.Getenv(keychainEnv) + if path == "" { + return + } + var creds []auth.Credentials + seen := map[string]bool{} + for _, url := range scriptKeychainURLs { + if seen[url] { + continue + } + seen[url] = true + if c, err := auth.Load(url); err == nil && c != nil { + creds = append(creds, *c) + } + } + if len(creds) == 0 { + _ = os.Remove(path) + return + } + body, err := json.Marshal(creds) + if err == nil { + err = os.WriteFile(path, body, 0o600) + } + if err != nil { + fmt.Fprintln(os.Stderr, "saving the keychain:", err) + os.Exit(1) + } +} + +func TestScripts(t *testing.T) { + testscript.Run(t, testscript.Params{ + Dir: filepath.Join("testdata", "script"), + UpdateScripts: *updateScripts, + // Scripts name the binary explicitly — `exec flagsmith ...` — so a + // typo'd command is an error rather than a silent shell lookup. + RequireExplicitExec: true, + Setup: setupScript, + Cmds: map[string]func(*testscript.TestScript, bool, []string){ + "fake": cmdFake, + "dump": cmdDump, + "cache": cmdCache, + "subst": cmdSubst, + }, + }) +} + +// backref spots a description that leans on a neighbouring case. Whole words +// only: "against" is not a back-reference to "again". +var backref = regexp.MustCompile(`\b(the same|above|again|previously|previous|earlier)\b`) + +// Every case in a script is described the way the engine's test data describes +// its own: a Given/When/Then triple, in that order, above the commands it +// covers. The scan keeps it honest — a case added without one fails here. +func TestEveryScriptCaseIsGivenWhenThen(t *testing.T) { + dir := filepath.Join("testdata", "script") + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) == 0 { + t.Fatal("no scripts found — the scan is broken") + } + for _, e := range entries { + if e.IsDir() { + continue + } + t.Run(e.Name(), func(t *testing.T) { + body, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + t.Fatal(err) + } + // Only the script itself, not the txtar file sections after it. + script := string(body) + if i := strings.Index(script, "\n-- "); i >= 0 { + script = script[:i] + } + // A case is a block of lines separated by blank ones. Blocks that + // run nothing — a bare `fake` setting, say — describe nothing. + for _, block := range strings.Split(script, "\n\n") { + lines := strings.Split(strings.TrimSpace(block), "\n") + if !slices.ContainsFunc(lines, func(l string) bool { + return strings.HasPrefix(strings.TrimPrefix(l, "! "), "exec ") + }) { + continue + } + var got []string + for _, l := range lines { + for _, want := range []string{"# Given:", "# When:", "# Then:"} { + if strings.HasPrefix(l, want) { + got = append(got, strings.TrimSuffix(want, ":")) + } + } + } + if want := []string{"# Given", "# When", "# Then"}; !slices.Equal(got, want) { + t.Errorf("case has %v, want %v, in:\n%s", got, want, strings.TrimSpace(block)) + } + // Each case must read on its own: a description that points at + // a neighbour ("the same environment") is only legible to + // someone reading the whole file top to bottom. + for _, l := range lines { + if !strings.HasPrefix(l, "# ") { + continue + } + if backref.MatchString(strings.ToLower(l)) { + t.Errorf("%q refers to another case — describe this one in full", strings.TrimSpace(l)) + } + } + } + }) + } +} + +// setupScript gives each script its own fake instance and points the CLI at it. +func setupScript(env *testscript.Env) error { + f := newFake() + env.Defer(f.srv.Close) + env.Values["fake"] = f + + // Fail closed. Every script is pinned to the fake by the environment, not + // by remembering to pass --api-url: a script that forgets cannot reach + // api.flagsmith.com and quietly authenticate as the developer. + env.Setenv("FLAGSMITH_API_URL", f.srv.URL) + env.Setenv(scopedEnvName(envAPIKey, f.srv.URL), masterKey) + + // $API is for cmpenv, which substitutes it in expected output — the fake's + // port changes every run and must not be baked into a golden file. + env.Setenv("API", f.srv.URL) + + // Credential variables are host-scoped, so their names carry the fake's + // port and no script can spell them literally. Export the names instead, + // for a script that needs to set one: + // + // env $SDK_KEY_VAR=ser.serverSideSecret + env.Setenv("SDK_KEY_VAR", scopedEnvName(envEnvironmentKey, f.srv.URL)) + env.Setenv("API_KEY_VAR", scopedEnvName(envAPIKey, f.srv.URL)) + env.Setenv("TOKEN_VAR", scopedEnvName(envAccessToken, f.srv.URL)) + + // An `env` in a script lasts to the end of that script, so a case that + // clears the credential changes what its neighbours start from. $MASTER_KEY + // lets a case put it back rather than depend on what ran before it. + env.Setenv("MASTER_KEY", masterKey) + env.Setenv("BEARER_TOKEN", bearerToken) + env.Setenv("HOME", env.WorkDir) + env.Setenv("XDG_CONFIG_HOME", filepath.Join(env.WorkDir, ".config")) + // os.UserCacheDir and os.UserConfigDir read these on Windows. + env.Setenv("LocalAppData", filepath.Join(env.WorkDir, "AppData", "Local")) + env.Setenv("AppData", filepath.Join(env.WorkDir, "AppData", "Roaming")) + + // $CACHE is the name cache the CLI will use, so a script can read it + // without spelling out a per-platform path. + env.Setenv("CACHE", cachePathFor(env.WorkDir, "")) + env.Setenv(keychainEnv, filepath.Join(env.WorkDir, "keychain.json")) + return nil +} + +// cmdFake configures the instance a script runs against, for the conditions +// that are properties of the backend rather than of the invocation. +// +// fake sdk-status 500 the SDK endpoints answer 500 +// fake sdk-delay 1200ms they take this long +// fake sdk-flags that environment key resolves the default flags +// fake sdk-flags none ...and this one resolves none +func cmdFake(ts *testscript.TestScript, neg bool, args []string) { + if neg || len(args) == 0 { + ts.Fatalf("usage: fake [value...]") + } + valueless := []string{"environments", "orgs", "feature-overrides", "workflow-gated", + "segment-override-missing", "edge-identities", "forget-requests", "features-default", "environments-named"} + if len(args) < 2 && !slices.Contains(valueless, args[0]) { + ts.Fatalf("fake %s needs a value", args[0]) + } + // These name a target and a fixture file, so say which is missing rather + // than indexing past the end of the arguments. + withFixture := []string{"feature-segments", "feature-states", "project-environments", + "server-keys", "projects"} + if len(args) < 3 && slices.Contains(withFixture, args[0]) { + ts.Fatalf("usage: fake %s ", args[0]) + } + f := ts.Value("fake").(*fakeInstance) + // Not locked here: several of these delegate to fixture helpers that take + // the lock themselves. Cases that touch fields directly lock around it. + set := func(fn func()) { f.mu.Lock(); defer f.mu.Unlock(); fn() } + switch args[0] { + case "sdk-status": + code, err := strconv.Atoi(args[1]) + ts.Check(err) + set(func() { f.sdkStatus = code }) + case "sdk-delay": + d, err := time.ParseDuration(args[1]) + ts.Check(err) + set(func() { f.sdkDelay = d }) + case "sdk-flags": + flags := sdkFlagsFrom(defaultFeatures()) + switch { + case len(args) > 2 && args[2] == "none": + flags = []map[string]any{} + case len(args) > 2 && args[2] == "unknown": + // Not in the map at all: the SDK API 401s, as it does for a key + // that belongs to no environment. + set(func() { delete(f.sdkEnvFlags, args[1]) }) + return + } + set(func() { f.sdkEnvFlags[args[1]] = flags }) + case "sdk-identity": + // This identity resolves max_items on at 99, so its own flags are + // distinguishable from the environment's defaults. + flags := sdkFlagsFrom(defaultFeatures()) + flags[1]["enabled"], flags[1]["feature_state_value"] = true, 99 + set(func() { f.sdkIdentityFlags[args[1]] = flags }) + case "features": + // fake features blob.json — the project's features, written out in the + // script so the fixture a case turns on is visible next to it. + items := scriptRows(ts, args[1]) + set(func() { f.features["101"] = items }) + case "segment-override": + // max_items alone, with or without an override for segment 12. + withSegmentOverride(f, args[1] == "on") + case "feature-overrides": + // max_items with two segment overrides and their feature-states, in + // priority order — the fixture the override views are read against. + withSegmentOverride(f, true) + withFeatureOverridesRows(f) + case "feature-segments": + // fake feature-segments 2 rows.json + id, err := strconv.Atoi(args[1]) + ts.Check(err) + withFeatureSegments(f, id, scriptRows(ts, args[2])...) + case "feature-states": + // fake feature-states 2 rows.json + id, err := strconv.Atoi(args[1]) + ts.Check(err) + withFeatureStates(f, id, scriptRows(ts, args[2])...) + case "identity-overrides": + // fake identity-overrides rows.json — one row per identity's override + // of one feature, as the core (non-edge) endpoints serve them. + var rows []struct { + Identifier string `json:"identifier"` + ID int `json:"id"` + Feature int `json:"feature"` + Enabled bool `json:"enabled"` + Value any `json:"value"` + } + ts.Check(json.Unmarshal([]byte(ts.ReadFile(args[1])), &rows)) + set(func() { + f.coreIdentities = map[string]int{} + f.coreOverrides = map[int]map[int]*fakeFS{} + for i, r := range rows { + f.coreIdentities[r.Identifier] = r.ID + f.coreOverrides[r.ID] = map[int]*fakeFS{ + r.Feature: {id: 9100 + i, enabled: r.Enabled, value: r.Value}, + } + } + }) + case "edge-overrides": + // The same, for a project that keeps its identities at the edge. + var rows []struct { + Identifier string `json:"identifier"` + Feature int `json:"feature"` + Enabled bool `json:"enabled"` + Value any `json:"value"` + } + ts.Check(json.Unmarshal([]byte(ts.ReadFile(args[1])), &rows)) + set(func() { + f.useEdge = true + f.edgeOverrides = map[string]map[int]*fakeFS{} + for _, r := range rows { + f.edgeOverrides[r.Identifier] = map[int]*fakeFS{ + r.Feature: {enabled: r.Enabled, value: r.Value}, + } + } + }) + case "features-default": + // The project's usual two features, as newFake starts with. + set(func() { f.features["101"] = defaultFeatures() }) + case "projects": + // fake projects 3 acme.json — the projects an organisation holds. + rows := scriptRows(ts, args[2]) + set(func() { f.projects[args[1]] = rows }) + case "project-environments": + // fake project-environments 202 envs.json — one project's environments, + // for the cases that pick a project other than the default one. + rows := scriptRows(ts, args[2]) + set(func() { f.envs[args[1]] = rows }) + case "server-keys": + // fake server-keys K2mVsGdXhZ8kQqZ9pJmNbJ keys.json — the server-side + // keys an environment already has. + rows := scriptRows(ts, args[2]) + set(func() { f.serverKeys[args[1]] = rows }) + case "forget-requests": + // The request log runs the length of a script, so a case that counts + // calls says where its own counting starts. + set(func() { f.reqLog = nil }) + case "workflow-gated": + withWorkflowGating(f) + case "segment-override-missing": + withMissingSegmentOverride(f) + case "edge-identities": + withEdgeIdentities(f) + case "orgs": + // fake orgs Acme=3 Beta=7 — or no pairs at all for an instance the + // credential can see no organisations in. + var orgs []map[string]any + for _, pair := range args[1:] { + name, id, ok := strings.Cut(pair, "=") + if !ok { + ts.Fatalf("fake orgs: %q is not name=id", pair) + } + n, err := strconv.Atoi(id) + ts.Check(err) + orgs = append(orgs, map[string]any{"id": n, "name": name}) + } + set(func() { f.orgs = orgs }) + case "org-fields": + // Extra API fields on Acme, for the cases about what --json passes through. + var n int + set(func() { n = len(f.orgs) }) + if n == 0 { + ts.Fatalf("fake org-fields: no organisations to add them to") + } + for _, pair := range args[1:] { + k, v, ok := strings.Cut(pair, "=") + if !ok { + ts.Fatalf("fake org-fields: %q is not key=value", pair) + } + set(func() { f.orgs[0][k] = scriptValue(v) }) + } + case "environments-named": + // fake environments-named Staging=keyA Staging=keyB — for the cases + // about names that do not identify one environment on their own. + var envs []map[string]any + for i, pair := range args[1:] { + name, key, ok := strings.Cut(pair, "=") + if !ok { + ts.Fatalf("fake environments-named: %q is not name=key", pair) + } + envs = append(envs, map[string]any{ + "id": i + 1, "name": name, "api_key": key, "project": 101, + }) + } + set(func() { f.envs["101"] = envs }) + case "environments": + // The two environments the Admin API knows about for project 101. + set(func() { + f.envs["101"] = []map[string]any{ + {"id": 1, "name": "Development", "api_key": "WqXhZk8sVY3dGgTqZ9pJmN", "project": 101, "description": "Local dev"}, + {"id": 2, "name": "Production", "api_key": "K2mVsGdXhZ8kQqZ9pJmNbJ", "project": 101, "description": "Live", "use_v2_feature_versioning": true}, + } + }) + default: + ts.Fatalf("unknown fake setting %q", args[0]) + } +} + +// scriptJSON decodes a fixture file the way the Go fixtures are written: +// encoding/json makes every number a float64, but the fake compares ids and +// counts against int, so a whole number decodes to int here. +func scriptJSON(ts *testscript.TestScript, file string, into any) { + ts.Check(json.Unmarshal([]byte(ts.ReadFile(file)), into)) +} + +// wholeNumbersToInt rewrites float64 values that are whole numbers as int, +// throughout a decoded document. +func wholeNumbersToInt(v any) any { + switch t := v.(type) { + case map[string]any: + for k, item := range t { + t[k] = wholeNumbersToInt(item) + } + case []any: + for i, item := range t { + t[i] = wholeNumbersToInt(item) + } + case float64: + if t == float64(int(t)) { + return int(t) + } + } + return v +} + +// scriptRows decodes a fixture file of API rows. +func scriptRows(ts *testscript.TestScript, file string) []map[string]any { + var rows []map[string]any + scriptJSON(ts, file, &rows) + for _, row := range rows { + wholeNumbersToInt(row) + } + return rows +} + +// scriptValue types a fixture value written in a script the way the API would +// carry it, so `fake org-fields force_2fa=true` sets a boolean and not "true". +func scriptValue(s string) any { + switch s { + case "true": + return true + case "false": + return false + } + if n, err := strconv.Atoi(s); err == nil { + return n + } + return s +} + +// cmdSubst expands environment variables in a file, in place. txtar bodies are +// copied out verbatim, so a fixture that has to name the fake writes $API and +// says so: +// +// subst config.json +func cmdSubst(ts *testscript.TestScript, neg bool, args []string) { + if neg || len(args) == 0 { + ts.Fatalf("usage: subst ...") + } + for _, name := range args { + path := ts.MkAbs(name) + body, err := os.ReadFile(path) + ts.Check(err) + expanded := os.Expand(string(body), func(k string) string { return ts.Getenv(k) }) + ts.Check(os.WriteFile(path, []byte(expanded), 0o644)) + } +} + +// cmdCache seeds the local name cache, for the cases that turn on what the CLI +// already knows without asking the Admin API. +// +// cache environments K2mVsGdXhZ8kQqZ9pJmNbJ=Production +func cmdCache(ts *testscript.TestScript, neg bool, args []string) { + if neg || len(args) == 0 { + ts.Fatalf("usage: cache [-url=] [=...]") + } + instance := ts.Value("fake").(*fakeInstance).srv.URL + if strings.HasPrefix(args[0], "-url=") { + instance = strings.TrimPrefix(args[0], "-url=") + args = args[1:] + } + names := map[string]string{} + for _, pair := range args[1:] { + k, v, ok := strings.Cut(pair, "=") + if !ok { + ts.Fatalf("cache: %q is not key=name", pair) + } + names[k] = v + } + + // Merge, so a case can seed more than one kind of name. + path := scriptCachePath(ts) + all := map[string]*cache.Names{} + if raw, err := os.ReadFile(path); err == nil { + _ = json.Unmarshal(raw, &all) + } + key := strings.TrimRight(instance, "/") + if all[key] == nil { + all[key] = &cache.Names{} + } + switch args[0] { + case "organisations": + all[key].Organisations = names + case "projects": + all[key].Projects = names + case "environments": + all[key].Environments = names + case "segments": + all[key].Segments = names + default: + ts.Fatalf("unknown cache kind %q", args[0]) + } + body, err := json.Marshal(all) + ts.Check(err) + ts.Check(os.MkdirAll(filepath.Dir(path), 0o755)) + ts.Check(os.WriteFile(path, body, 0o600)) +} + +// cachePathFor is where the CLI under test keeps its name cache: what +// os.UserCacheDir resolves to for the script's HOME, not for this process's. +// Writing it via cache.Merge would target the developer's own cache directory. +func cachePathFor(home, xdg string) string { + dir := filepath.Join(home, ".cache") + switch { + case runtime.GOOS == "darwin": + dir = filepath.Join(home, "Library", "Caches") + case runtime.GOOS == "windows": + // os.UserCacheDir reads %LocalAppData% here, which Setup points at the + // script's own directory. + dir = filepath.Join(home, "AppData", "Local") + case xdg != "": + dir = xdg + } + return filepath.Join(dir, "flagsmith", "cache.json") +} + +func scriptCachePath(ts *testscript.TestScript) string { + return cachePathFor(ts.Getenv("HOME"), ts.Getenv("XDG_CACHE_HOME")) +} + +// cmdDump writes a slice of what the fake observed to a file, so a script can +// assert on it with cmp — which means -update-scripts maintains it too. +// +// exec flagsmith evaluate +// dump requests requests.txt +// cmpenv requests.txt expect-requests +func cmdDump(ts *testscript.TestScript, neg bool, args []string) { + if neg || len(args) != 2 { + ts.Fatalf("usage: dump ") + } + f := ts.Value("fake").(*fakeInstance) + var lines []string + switch args[0] { + case "requests": + // Sorted: reads fan out concurrently, so arrival order is not a + // property of the code — only which requests were made, and which + // were not. + lines = f.requests() + sort.Strings(lines) + case "sdk-agents": + lines = f.sdkAgents() + case "sdk-keys": + lines = f.sdkSentKeys() + case "env-lists": + lines = []string{strconv.Itoa(f.environmentLists()) + " environment list calls"} + default: + ts.Fatalf("unknown dump %q", args[0]) + } + out := "" + if len(lines) > 0 { + out = strings.Join(lines, "\n") + "\n" + } + if err := os.WriteFile(ts.MkAbs(args[1]), []byte(out), 0o644); err != nil { + ts.Fatalf("writing %s: %v", args[1], err) + } + fmt.Fprintf(ts.Stdout(), "%d %s\n", len(lines), args[0]) +} diff --git a/internal/cmd/testdata/script/api-url-flag.txtar b/internal/cmd/testdata/script/api-url-flag.txtar new file mode 100644 index 0000000..41bc4ca --- /dev/null +++ b/internal/cmd/testdata/script/api-url-flag.txtar @@ -0,0 +1,38 @@ +# The instance is named by a flag here rather than by the environment, so these +# cases can say where in the command line that flag is allowed to sit. +env FLAGSMITH_API_URL= + +# Given: an instance named by a flag placed after the subcommand +# When: auth status runs +# Then: that instance answers +exec flagsmith auth status --api $API +cp stdout after.txt +stdout 'Acme' + +# Given: an instance named by a flag placed before the subcommand +# When: auth status runs +# Then: it behaves exactly as it does with the flag after the subcommand +exec flagsmith --api $API auth status +cmp stdout after.txt + +# Given: an instance named by a flag placed between nested subcommands +# When: auth status runs +# Then: it behaves exactly as it does with the flag after the subcommand +exec flagsmith auth --api $API status +cmp stdout after.txt + +# Given: an instance named by the canonical --api-url spelling +# When: auth status runs +# Then: it behaves exactly as the --api alias does +exec flagsmith auth status --api-url $API +cmp stdout after.txt + +# Given: a CLI documenting its global flags +# When: --help is shown +# Then: --api-url is documented and the --api alias is kept out of the way +exec flagsmith --help +stdout '\-\-api-url' +! stdout '\-\-api ' + +-- flagsmith.json -- +{"project": 101} diff --git a/internal/cmd/testdata/script/api.txtar b/internal/cmd/testdata/script/api.txtar new file mode 100644 index 0000000..522ac9f --- /dev/null +++ b/internal/cmd/testdata/script/api.txtar @@ -0,0 +1,81 @@ +# /api/v1/echo/ reflects the request back, so what the CLI sent is what these +# cases read on stdout. + +# Given: an instance reachable with an Admin credential +# When: a path is fetched with no other arguments +# Then: it is a GET, and the Admin credential is applied without being asked for +exec flagsmith api api/v1/echo/ +stdout '"method":"GET"' +stdout '"authorization":"Api-Key '$MASTER_KEY'"' + +# Given: an instance with one organisation +# When: a response is filtered with --jq +# Then: only the filtered value is printed +exec flagsmith api api/v1/organisations/ --jq .results[].name +stdout '^Acme$' + +# Given: a request with one typed field and one raw field +# When: it is sent +# Then: a field implies POST with a JSON body, -F typing its value and -f leaving it a string +exec flagsmith api api/v1/echo/ -F n=3 -f s=3 +stdout '"method":"POST"' +stdout '"content_type":"application/json"' +stdout '\\"n\\":3' +stdout '\\"s\\":\\"3\\"' + +# Given: a request with a field and an explicit GET +# When: it is sent +# Then: the field becomes a query parameter rather than a body +exec flagsmith api api/v1/echo/ -X GET -F a=1 +stdout '"method":"GET"' +stdout '"query":"a=1"' + +# Given: a path that already carries a query string +# When: a field is added to it +# Then: the field joins the existing query rather than replacing it +exec flagsmith api 'api/v1/echo/?organisation=3' -X GET -F page=2 --jq .query +stdout '^organisation=3&page=2$' + +# Given: a raw body on stdin +# When: it is sent with --input - +# Then: the body travels verbatim +stdin body.json +exec flagsmith api api/v1/echo/ -X POST --input - --jq .body +stdout '^\{"x":1\}$' + +# Given: a request carrying a header of the caller's own +# When: it is sent +# Then: the header reaches the server +exec flagsmith api api/v1/echo/ -H 'X-Custom: hi' +stdout '"custom":"hi"' + +# Given: a request whose status the caller wants to see +# When: -i is passed +# Then: the status line is printed before the body +exec flagsmith api api/v1/echo/ -i +stdout 'HTTP/1.1 200' + +# Given: a path the instance does not serve +# When: it is fetched +# Then: the invocation fails, carrying the status +! exec flagsmith api api/v1/nope/ +stderr '404' + +# Given: an environment key alongside the Admin credential +# When: --sdk is passed +# Then: the environment key is sent and the Admin credential is not +env $SDK_KEY_VAR=someClientKey +exec flagsmith api api/v1/echo/ --sdk +stdout '"envkey":"someClientKey"' +stdout '"authorization":""' + +# Given: a raw body and a field, which describe the body two different ways +# When: both are passed +# Then: it is a usage error +! exec flagsmith api api/v1/echo/ --input - -F a=1 +stderr 'Usage:' + +-- flagsmith.json -- +{"project": 101} +-- body.json -- +{"x":1} diff --git a/internal/cmd/testdata/script/cli.txtar b/internal/cmd/testdata/script/cli.txtar new file mode 100644 index 0000000..a487e62 --- /dev/null +++ b/internal/cmd/testdata/script/cli.txtar @@ -0,0 +1,57 @@ +# Given: a build carrying a version +# When: --version is asked for +# Then: that version is printed +exec flagsmith --version +stdout 'dev' + +# Given: a directory with no flagsmith.json in it, and no credentials +# When: the CLI is invoked bare +# Then: it nudges towards the command that would set both up +mkdir bare +cd bare +env $API_KEY_VAR= +exec flagsmith +stdout 'flagsmith init' +cd $WORK +env $API_KEY_VAR=$MASTER_KEY + +# Given: a config file with a misspelled field +# When: the configuration is read +# Then: the warning goes to stderr, leaving stdout clean for the document itself +exec flagsmith config -c typo.json +! stdout 'unknown field' +stderr 'unknown field' + +# Given: FLAGSMITH_JSON_OUTPUT set +# When: a command that has both shapes runs +# Then: the machine-readable one is chosen without --json being passed +env FLAGSMITH_JSON_OUTPUT=1 +exec flagsmith config +stdout '^\{' +env FLAGSMITH_JSON_OUTPUT= + +# Given: a Master API key in a host-scoped variable +# When: the credential is described as JSON +# Then: it names the kind, the organisations it reaches and the exact variable it came from +exec flagsmith auth status --json --jq '.kind, .organisations[0].name, .credentialSource' +stdout '^master$' +stdout '^Acme$' +stdout '^\$'$API_KEY_VAR'$' + +# Given: a Master API key in a host-scoped variable +# When: the token is asked for as JSON +# Then: the key itself is what comes back +exec flagsmith auth token --json --jq .token +stdout '^'$MASTER_KEY'$' + +# Given: a cold name cache and a credential that can see one organisation +# When: the credential is described +# Then: the organisation's name is remembered for later commands +cache organisations +exec flagsmith auth status +grep '"3":"Acme"' $CACHE + +-- flagsmith.json -- +{"project": 101} +-- typo.json -- +{"project": 1, "enviroment": "typo"} diff --git a/internal/cmd/testdata/script/config.txtar b/internal/cmd/testdata/script/config.txtar new file mode 100644 index 0000000..50052da --- /dev/null +++ b/internal/cmd/testdata/script/config.txtar @@ -0,0 +1,131 @@ +# `config` reads no API, so these run against the real defaults rather than the +# fake: the point of the command is which source a setting came from. +env FLAGSMITH_API_URL= + +# Given: a repository with no flagsmith.json in it +# When: the configuration is shown +# Then: the URLs come from their defaults, and nothing is configured +exec flagsmith config --json +cmp stdout expect-defaults + +# Given: a config file, an environment variable and a flag each setting something, and a cache of names +# When: the configuration is shown +# Then: each setting names the source it came from, and ids carry their cached names +cache -url=https://acme.example organisations 3=Acme +cache -url=https://acme.example projects 12345=my-app +cache -url=https://acme.example environments StagingKey123=Staging +env FLAGSMITH_ENVIRONMENT=StagingKey123 +exec flagsmith config --json -c layered.json --sdk-api-url https://flags.example +cmp stdout expect-layered + +# Given: a repository with no flagsmith.json in it +# When: only --api-url is set +# Then: the SDK API URL follows it, still as a default rather than a setting of its own +env FLAGSMITH_ENVIRONMENT= +exec flagsmith config --json --api-url https://self.example +stdout '"sdkApiUrl": .\n *"value": "https://self.example",\n *"source": "default"' +stdout '"apiUrl": .\n *"value": "https://self.example",\n *"source": "cli"' + +# Given: a config file outside the current repository +# When: it is named with --config-path +# Then: that path is the config, sourced from the command line +exec flagsmith config --json -c elsewhere/flagsmith.json +stdout '"configPath": .\n *"value": ".*elsewhere/flagsmith.json",\n *"source": "cli"' +stdout '"project": .\n *"value": 7' + +# Given: a config file setting a project +# When: the configuration is shown without --json +# Then: values and their sources are laid out for a human +exec flagsmith config -c layered.json +stdout '12345' +stdout 'config' +stdout 'default' + +# Given: a config file with a misspelled field +# When: the configuration is shown +# Then: the unknown field is called out rather than silently ignored +exec flagsmith config -c typo.json +stderr 'unknown field' + +# Given: FLAGSMITH_PROJECT set to something that is not an id +# When: the configuration is shown +# Then: it is carried through as a name +env FLAGSMITH_PROJECT=my-app +exec flagsmith config --json +stdout '"project": .\n *"value": "my-app",\n *"source": "env"' + +# Given: a server-side key offered as the environment +# When: it is passed with -e +# Then: it is refused, pointing at the variable meant for secrets +env FLAGSMITH_PROJECT= +! exec flagsmith config -e ser.AbCd +stderr 'FLAGSMITH_ENVIRONMENT_KEY' + +-- layered.json -- +{ + "project": 12345, + "organisation": 3, + "environment": "K2mVsGdXhZ8kQqZ9pJmNbJ", + "apiUrl": "https://acme.example" +} +-- elsewhere/flagsmith.json -- +{"project": 7} +-- typo.json -- +{"project": 1, "enviroment": "typo"} +-- expect-defaults -- +{ + "configPath": { + "value": null, + "source": "default" + }, + "project": { + "value": null, + "source": "default" + }, + "organisation": { + "value": null, + "source": "default" + }, + "environment": { + "value": null, + "source": "default" + }, + "apiUrl": { + "value": "https://api.flagsmith.com", + "source": "default" + }, + "sdkApiUrl": { + "value": "https://edge.api.flagsmith.com", + "source": "default" + } +} +-- expect-layered -- +{ + "configPath": { + "value": "layered.json", + "source": "cli" + }, + "project": { + "value": 12345, + "name": "my-app", + "source": "config" + }, + "organisation": { + "value": 3, + "name": "Acme", + "source": "config" + }, + "environment": { + "value": "StagingKey123", + "name": "Staging", + "source": "env" + }, + "apiUrl": { + "value": "https://acme.example", + "source": "config" + }, + "sdkApiUrl": { + "value": "https://flags.example", + "source": "cli" + } +} diff --git a/internal/cmd/testdata/script/confirm.txtar b/internal/cmd/testdata/script/confirm.txtar new file mode 100644 index 0000000..dda39d2 --- /dev/null +++ b/internal/cmd/testdata/script/confirm.txtar @@ -0,0 +1,19 @@ +# Confirmations need a terminal to ask on, so these run under ttyin. + +[!unix] skip 'ttyin needs a pty, which testscript provides on unix only' + +# Given: FLAGSMITH_NO_INPUT set to a falsey value, and a terminal to prompt on +# When: a destructive command runs and the confirmation is declined +# Then: the prompt still ran, and declining it deleted nothing — a falsey value switches the behaviour off rather than merely being set +env FLAGSMITH_NO_INPUT=false +ttyin -stdin decline +exec flagsmith project delete 101 +stderr 'Aborted; nothing deleted\.' +dump requests requests.txt +! grep 'DELETE /api/v1/projects/101/' requests.txt +env FLAGSMITH_NO_INPUT= + +-- flagsmith.json -- +{"project": 101} +-- decline -- +n diff --git a/internal/cmd/testdata/script/credentials.txtar b/internal/cmd/testdata/script/credentials.txtar new file mode 100644 index 0000000..6369426 --- /dev/null +++ b/internal/cmd/testdata/script/credentials.txtar @@ -0,0 +1,77 @@ +# Which credential a command uses, and what it says about where it came from. +# The variables are host-scoped, so $API_KEY_VAR and friends carry the fake's +# host in their names. + +# Given: an access token in its host-scoped variable +# When: the credential is described +# Then: it identifies the user it belongs to, and names the variable it came from +env $API_KEY_VAR= +env $TOKEN_VAR=$BEARER_TOKEN +exec flagsmith auth status +stdout 'kim@example.com' +stdout '\$'$TOKEN_VAR + +# Given: an access token offered in the variable meant for Master API keys +# When: the credential is described +# Then: it is refused, pointing at the variable that would have taken it +env $TOKEN_VAR= +env $API_KEY_VAR=$BEARER_TOKEN +! exec flagsmith auth status +stderr 'FLAGSMITH_ACCESS_TOKEN' + +# Given: a Master API key and an access token, both set +# When: the credential is described +# Then: the Master API key wins +env $API_KEY_VAR=$MASTER_KEY +env $TOKEN_VAR=$BEARER_TOKEN +exec flagsmith auth status +stdout '\$'$API_KEY_VAR + +# Given: a server-side key offered in the variable meant for Master API keys +# When: the credential is described +# Then: it is refused, pointing at the variable meant for secrets +env $TOKEN_VAR= +env $API_KEY_VAR=ser.AbCdEf1234 +! exec flagsmith auth status +stderr 'FLAGSMITH_ENVIRONMENT_KEY' + +# Given: an OAuth credential in the keychain and a Master API key in the environment +# When: the credential is described +# Then: the environment wins, and says so +env $API_KEY_VAR=$MASTER_KEY +exec flagsmith auth status +stdout '\$'$API_KEY_VAR + +# Given: an expired OAuth credential in the keychain, and no environment credential +# When: a command refreshes it +# Then: the refreshed token is written back to the keychain +env $API_KEY_VAR= +exec flagsmith auth status +grep 'cmd-access' $KEYCHAIN +! grep 'stale-access' $KEYCHAIN + +# Given: a Master API key in its host-scoped variable +# When: the credential is described, and then asked for +# Then: it is identified as a Master API key, reaching Acme, and the key itself is what `auth token` prints +env $API_KEY_VAR=$MASTER_KEY +exec flagsmith auth status +stdout 'Master API key' +stdout 'Acme' +stdout '\$'$API_KEY_VAR +exec flagsmith auth token +stdout '^'$MASTER_KEY'$' + +# Given: a config file naming an instance, and a credential scoped to it +# When: the credential is described with no --api-url +# Then: the instance from the config is the one reached +env FLAGSMITH_API_URL= +subst with-api-url.json +exec flagsmith auth status -c with-api-url.json +stdout 'Acme' + +-- flagsmith.json -- +{"project": 101} +-- with-api-url.json -- +{"project": 1, "apiUrl": "$API"} +-- keychain.json -- +[{"api_url": "$API", "kind": "oauth", "access_token": "stale-access", "refresh_token": "cmd-refresh", "expires_at": "2020-01-01T00:00:00Z"}] diff --git a/internal/cmd/testdata/script/environment-document.txtar b/internal/cmd/testdata/script/environment-document.txtar new file mode 100644 index 0000000..56495bd --- /dev/null +++ b/internal/cmd/testdata/script/environment-document.txtar @@ -0,0 +1,25 @@ +fake environments + +# Given: an environment named Production +# When: its document is fetched by name +# Then: the document is that environment's, carrying its feature states +exec flagsmith environment document Production --jq '.api_key, (.feature_states | length)' +cmp stdout expect-production + +# Given: a configuration naming Development as the environment +# When: a document is fetched with no argument +# Then: the context environment is the one fetched +exec flagsmith environment document --jq .api_key +stdout '^WqXhZk8sVY3dGgTqZ9pJmN$' + +# Given: an environment document with two feature states in it +# When: it is filtered with --jq +# Then: the filter applies to the document +exec flagsmith environment document Production --jq '.feature_states | length' +stdout '^2$' + +-- flagsmith.json -- +{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN"} +-- expect-production -- +K2mVsGdXhZ8kQqZ9pJmNbJ +2 diff --git a/internal/cmd/testdata/script/environment-key.txtar b/internal/cmd/testdata/script/environment-key.txtar new file mode 100644 index 0000000..1b792d7 --- /dev/null +++ b/internal/cmd/testdata/script/environment-key.txtar @@ -0,0 +1,34 @@ +fake environments + +# Given: an environment with one server-side key on it +# When: its keys are listed +# Then: each key's name, id and whether it is active are shown +fake server-keys K2mVsGdXhZ8kQqZ9pJmNbJ existing-key.json +exec flagsmith environment key list Production +cmp stdout expect-list + +# Given: an environment to mint a server-side key for +# When: one is created +# Then: the creation is confirmed and the secret is printed, this being the only time it can be +exec flagsmith environment key create Production --name backend +stderr 'Created server-side key backend' +stdout 'ser.mintedKey000000000' +dump requests requests.txt +grep '"name":"backend"' requests.txt + +# Given: an environment with a server-side key on it +# When: that key is deleted +# Then: the deletion is confirmed and the key is gone +fake server-keys K2mVsGdXhZ8kQqZ9pJmNbJ existing-key.json +exec flagsmith environment key delete Production 14 --yes +stderr 'Deleted server-side key 14' +exec flagsmith environment key list Production +! stdout 'CI key' + +-- flagsmith.json -- +{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN"} +-- existing-key.json -- +[{"id": 14, "name": "CI key", "active": true, "key": "ser.existing", "created_at": "2026-07-01T00:00:00Z"}] +-- expect-list -- +NAME ID ACTIVE CREATED EXPIRES AT +CI key 14 true 2026-07-01T00:00:00Z - diff --git a/internal/cmd/testdata/script/environment.txtar b/internal/cmd/testdata/script/environment.txtar new file mode 100644 index 0000000..b9ce98f --- /dev/null +++ b/internal/cmd/testdata/script/environment.txtar @@ -0,0 +1,97 @@ +fake environments + +# Given: a project with two environments +# When: they are listed +# Then: each is shown with its client-side key and description, and counted +exec flagsmith environment list +cmp stdout expect-list + +# Given: a project with two environments +# When: the command is spelled `env` +# Then: it is an alias for environment +exec flagsmith env list +stdout 'Development' + +# Given: an environment named Production, and a cache that knows the project's name +# When: it is fetched +# Then: the list row and the cached name are enough — neither the environment nor the project is retrieved +cache projects 101=acme-api +exec flagsmith environment get Production +cmp stdout expect-get +dump requests requests.txt +! grep 'GET /api/v1/environments/K2mVsGdXhZ8kQqZ9pJmNbJ/ ' requests.txt +! grep 'GET /api/v1/projects/101/ ' requests.txt + +# Given: an environment named Production, and a cache that does not know the project's name +# When: it is fetched +# Then: the view degrades to the bare project id rather than inventing a name +cache projects +exec flagsmith environment get Production +stdout '101' +! stdout 'acme-api' + +# Given: an environment named Production +# When: it is fetched through --jq +# Then: --jq implies JSON, which retrieves the environment for the richer payload +exec flagsmith environment get Production --jq .project +stdout '^101$' +dump requests requests.txt +grep -count=1 'GET /api/v1/environments/K2mVsGdXhZ8kQqZ9pJmNbJ/ ' requests.txt + +# Given: an environment whose retrieve payload is richer than its list row +# When: it is fetched as JSON +# Then: the retrieve is what answers, so the raw API fields are all present +fake forget-requests +exec flagsmith environment get Production --json --jq '.use_v2_feature_versioning, .api_key' +cmp stdout expect-json +dump requests requests.txt +grep -count=1 'GET /api/v1/environments/K2mVsGdXhZ8kQqZ9pJmNbJ/ ' requests.txt + +# Given: a project taken from the configuration +# When: an environment is created in it +# Then: the project comes from context, and the creation is confirmed +exec flagsmith environment create Staging +stderr 'Created environment Staging' +dump requests requests.txt +grep '"name":"Staging","project":101' requests.txt + +# Given: an environment named Production +# When: its description is changed +# Then: the new description reaches the request body +exec flagsmith environment update Production --description 'prod live' +dump requests requests.txt +grep '"description":"prod live"' requests.txt + +# Given: an environment named Development +# When: it is deleted +# Then: the deletion is confirmed and the environment is gone +exec flagsmith environment delete Development --yes +stderr 'Deleted environment Development' +exec flagsmith environment list +! stdout 'Development' + +# Given: an environment named Production +# When: it is cloned under a new name +# Then: the clone is confirmed and the new name reaches the request body +fake environments +exec flagsmith environment clone Production 'Production Copy' +stderr 'Cloned Production into Production Copy' +dump requests requests.txt +grep '"name":"Production Copy"' requests.txt + +-- flagsmith.json -- +{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN"} +-- expect-list -- +NAME KEY DESCRIPTION +Development WqXhZk8sVY3dGgTqZ9pJmN Local dev +Production K2mVsGdXhZ8kQqZ9pJmNbJ Live + +2 environments +-- expect-get -- +Environment Production (K2mVsGdXhZ8kQqZ9pJmNbJ) +Project acme-api (101) +Description Live +Versioning v2 +-- expect-json -- +true +K2mVsGdXhZ8kQqZ9pJmNbJ diff --git a/internal/cmd/testdata/script/evaluate-deadline.txtar b/internal/cmd/testdata/script/evaluate-deadline.txtar new file mode 100644 index 0000000..74b3801 --- /dev/null +++ b/internal/cmd/testdata/script/evaluate-deadline.txtar @@ -0,0 +1,10 @@ +# Given: an SDK API slower than the timeout the user asked for +# When: evaluation is attempted +# Then: the invocation deadline bounds it, rather than the SDK's own default +fake sdk-delay 1200ms +env FLAGSMITH_TIMEOUT=1 +! exec flagsmith evaluate +stderr 'deadline exceeded' + +-- flagsmith.json -- +{"environment": "WqXhZk8sVY3dGgTqZ9pJmN"} diff --git a/internal/cmd/testdata/script/evaluate-empty.txtar b/internal/cmd/testdata/script/evaluate-empty.txtar new file mode 100644 index 0000000..66101b3 --- /dev/null +++ b/internal/cmd/testdata/script/evaluate-empty.txtar @@ -0,0 +1,22 @@ +fake sdk-flags WqXhZk8sVY3dGgTqZ9pJmN none + +# Given: an environment that resolves no flags at all +# When: it is evaluated +# Then: that is reported, not treated as an error +exec flagsmith evaluate +stdout 'No flags\.' + +# Given: an environment that resolves no flags at all +# When: a hydration state is asked for with --js +# Then: the state is still emitted, but stderr warns that nothing can hydrate it +exec flagsmith evaluate --js +stderr 'Warning:' +cmpenv stdout expect-js + +-- flagsmith.json -- +{"environment": "WqXhZk8sVY3dGgTqZ9pJmN"} +-- expect-js -- +{ + "api": "$API/api/v1/", + "flags": {} +} diff --git a/internal/cmd/testdata/script/evaluate-environment-name-errors.txtar b/internal/cmd/testdata/script/evaluate-environment-name-errors.txtar new file mode 100644 index 0000000..2eaeb9d --- /dev/null +++ b/internal/cmd/testdata/script/evaluate-environment-name-errors.txtar @@ -0,0 +1,27 @@ +fake sdk-flags WqXhZk8sVY3dGgTqZ9pJmN unknown +fake sdk-flags K2mVsGdXhZ8kQqZ9pJmNbJ + +# Given: a config naming an environment by name, a cold cache, and no credentials +# When: it is evaluated +# Then: it says how to authenticate — a name cannot be resolved without a credential +env $API_KEY_VAR= +! exec flagsmith evaluate +stderr 'Run `flagsmith login`' + +# Given: a cache where two projects' environments share the name being asked for +# When: it is evaluated with an Admin credential +# Then: the ambiguity defers to the Admin API, which scopes the name to this project +cache environments WqXhZk8sVY3dGgTqZ9pJmN=Development OtherProjectDevKey1234=Development +env $API_KEY_VAR=$MASTER_KEY +fake environments +fake sdk-flags WqXhZk8sVY3dGgTqZ9pJmN +exec flagsmith evaluate -c by-development.json +dump sdk-keys keys.txt +cmp keys.txt expect-development-key + +-- flagsmith.json -- +{"project": 101, "environment": "Production"} +-- by-development.json -- +{"project": 101, "environment": "Development"} +-- expect-development-key -- +WqXhZk8sVY3dGgTqZ9pJmN diff --git a/internal/cmd/testdata/script/evaluate-environment-name-lookup.txtar b/internal/cmd/testdata/script/evaluate-environment-name-lookup.txtar new file mode 100644 index 0000000..07a18ee --- /dev/null +++ b/internal/cmd/testdata/script/evaluate-environment-name-lookup.txtar @@ -0,0 +1,22 @@ +fake sdk-flags WqXhZk8sVY3dGgTqZ9pJmN unknown +fake sdk-flags K2mVsGdXhZ8kQqZ9pJmNbJ +fake environments + +# Given: a config naming an environment by name, an Admin credential, and a cold cache +# When: it is evaluated twice +# Then: the first run costs one Admin lookup and the second costs none — the name was remembered +exec flagsmith evaluate +stdout '2 flags' +exec flagsmith evaluate +dump env-lists lists.txt +cmp lists.txt expect-one-list +dump sdk-keys keys.txt +cmp keys.txt expect-production-keys + +-- flagsmith.json -- +{"project": 101, "environment": "Production"} +-- expect-one-list -- +1 environment list calls +-- expect-production-keys -- +K2mVsGdXhZ8kQqZ9pJmNbJ +K2mVsGdXhZ8kQqZ9pJmNbJ diff --git a/internal/cmd/testdata/script/evaluate-environment-name.txtar b/internal/cmd/testdata/script/evaluate-environment-name.txtar new file mode 100644 index 0000000..32f24aa --- /dev/null +++ b/internal/cmd/testdata/script/evaluate-environment-name.txtar @@ -0,0 +1,44 @@ +fake sdk-flags WqXhZk8sVY3dGgTqZ9pJmN unknown +fake sdk-flags K2mVsGdXhZ8kQqZ9pJmNbJ + +# Given: a config naming an environment by name, no Admin credential, and a cache that knows that name +# When: it is evaluated +# Then: the name resolves from the cache, costing no Admin call at all +cache environments K2mVsGdXhZ8kQqZ9pJmNbJ=Production +env $API_KEY_VAR= +exec flagsmith evaluate +stdout '2 flags' +dump sdk-keys keys.txt +cmp keys.txt expect-production-key +dump env-lists lists.txt +cmp lists.txt expect-no-lists + +# Given: a config naming an environment by key, no Admin credential, and a cache that knows another name +# When: -e names that other environment +# Then: -e wins over the config, and still costs no Admin call +cache environments K2mVsGdXhZ8kQqZ9pJmNbJ=Production +env $API_KEY_VAR= +exec flagsmith evaluate -c by-key.json -e Production +dump sdk-keys keys.txt +grep 'K2mVsGdXhZ8kQqZ9pJmNbJ' keys.txt +dump env-lists lists.txt +cmp lists.txt expect-no-lists + +# Given: a config naming an environment by key the cache has never seen, and no credentials at all +# When: it is evaluated +# Then: a key is used as-is — never resolved, so no Admin call and no login needed +exec flagsmith evaluate -c by-production-key.json +stdout '2 flags' +dump env-lists lists.txt +cmp lists.txt expect-no-lists + +-- flagsmith.json -- +{"project": 101, "environment": "Production"} +-- by-key.json -- +{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN"} +-- by-production-key.json -- +{"project": 101, "environment": "K2mVsGdXhZ8kQqZ9pJmNbJ"} +-- expect-production-key -- +K2mVsGdXhZ8kQqZ9pJmNbJ +-- expect-no-lists -- +0 environment list calls diff --git a/internal/cmd/testdata/script/evaluate-environment.txtar b/internal/cmd/testdata/script/evaluate-environment.txtar new file mode 100644 index 0000000..100e084 --- /dev/null +++ b/internal/cmd/testdata/script/evaluate-environment.txtar @@ -0,0 +1,20 @@ +# Given: a config naming no environment, and no environment key in the setting +# When: evaluation is attempted +# Then: the error names the way in, rather than just failing to resolve +! exec flagsmith evaluate +stderr 'FLAGSMITH_ENVIRONMENT_KEY' + +# Given: a server-side key as the only environment reference, as it is in CI +# When: evaluation is attempted +# Then: it resolves, sending that key and nothing else to the SDK API +fake sdk-flags ser.serverSideSecret +env $SDK_KEY_VAR=ser.serverSideSecret +exec flagsmith evaluate +stdout '2 flags' +dump sdk-keys keys.txt +cmp keys.txt expect-keys + +-- flagsmith.json -- +{} +-- expect-keys -- +ser.serverSideSecret diff --git a/internal/cmd/testdata/script/evaluate-failures.txtar b/internal/cmd/testdata/script/evaluate-failures.txtar new file mode 100644 index 0000000..a53fbf1 --- /dev/null +++ b/internal/cmd/testdata/script/evaluate-failures.txtar @@ -0,0 +1,10 @@ +# Given: an SDK API answering 500 +# When: evaluation is attempted +# Then: the failure carries the status, in the CLI's own words with no SDK prefixes +fake sdk-status 500 +! exec flagsmith evaluate +stderr '500' +! stderr 'flagsmith:' + +-- flagsmith.json -- +{"environment": "WqXhZk8sVY3dGgTqZ9pJmN"} diff --git a/internal/cmd/testdata/script/evaluate-identity-rejections.txtar b/internal/cmd/testdata/script/evaluate-identity-rejections.txtar new file mode 100644 index 0000000..f3a4c8a --- /dev/null +++ b/internal/cmd/testdata/script/evaluate-identity-rejections.txtar @@ -0,0 +1,23 @@ +# Given: traits but no identity to persist them against +# When: --persist is passed +# Then: it is a usage error naming the flag that is missing +! exec flagsmith evaluate --trait country=US --persist +stderr '--identity' +stderr 'Usage:' + +# Given: a trait with no key at all +# When: it is passed +# Then: it is a usage error quoting the trait as it was written +! exec flagsmith evaluate --trait plan +stderr 'plan' +stderr 'Usage:' + +# Given: a trait whose key is empty +# When: it is passed +# Then: it is a usage error quoting the trait as it was written +! exec flagsmith evaluate --trait =premium +stderr '=premium' +stderr 'Usage:' + +-- flagsmith.json -- +{"environment": "WqXhZk8sVY3dGgTqZ9pJmN"} diff --git a/internal/cmd/testdata/script/evaluate-identity.txtar b/internal/cmd/testdata/script/evaluate-identity.txtar new file mode 100644 index 0000000..d17ba8a --- /dev/null +++ b/internal/cmd/testdata/script/evaluate-identity.txtar @@ -0,0 +1,71 @@ +fake sdk-identity user-123 + +# Given: an identity whose flags differ from the environment's defaults +# When: it is evaluated with --identity +# Then: the identity's own values are resolved, and it is identified transiently +exec flagsmith evaluate --identity user-123 +stdout '99' +dump requests requests.txt +cmp requests.txt expect-identify + +# Given: an identity whose flags differ from the environment's defaults +# When: it is addressed as --identifier, the spelling the flag commands use +# Then: user-123's own values are resolved — both spellings name the identity +exec flagsmith evaluate --identifier user-123 +stdout '99' + +# Given: an identity whose flags differ from the environment's defaults +# When: it is addressed as -i +# Then: user-123's own values are resolved — the shorthand names the identity too +exec flagsmith evaluate -i user-123 +stdout '99' + +# Given: an identity and four traits whose values look like different types +# When: it is evaluated with those traits +# Then: each trait is typed by inference — string, integer, float and boolean +exec flagsmith evaluate --identity typed --trait plan=premium --trait age=42 --trait score=1.5 --trait beta=true +dump requests requests.txt +grep '"trait_key":"plan","trait_value":"premium"' requests.txt +grep '"trait_key":"age","trait_value":42' requests.txt +grep '"trait_key":"score","trait_value":1.5' requests.txt +grep '"trait_key":"beta","trait_value":true' requests.txt + +# Given: an identity and a trait +# When: --persist is passed +# Then: the identity is no longer transient +exec flagsmith eval --identity user-123 --trait plan=premium --persist +dump requests requests.txt +grep '"transient":false' requests.txt + +# Given: an anonymous identity that resolves its own flags +# When: traits are passed with no identity +# Then: an anonymous transient identity is evaluated +fake sdk-identity '' +exec flagsmith evaluate --trait country=US +stdout '99' +dump requests requests.txt +grep '"identifier":"","traits":\[.*\],"transient":true' requests.txt + +# Given: an identity and a trait whose value is empty +# When: it is evaluated +# Then: the trait is sent set to the empty string — a what-if worth asking +exec flagsmith evaluate --identity user-123 --trait plan= +dump requests requests.txt +grep '"trait_key":"plan","trait_value":""' requests.txt + +# Given: an identity whose flags differ from the environment's defaults +# When: one feature is named alongside --json +# Then: the flag is printed on its own, with no EvaluationResult around it +exec flagsmith evaluate max_items --identity user-123 --json +cmp stdout expect-one-flag + +-- flagsmith.json -- +{"environment": "WqXhZk8sVY3dGgTqZ9pJmN"} +-- expect-identify -- +POST /api/v1/identities/ [environment-key] {"identifier":"user-123","transient":true} +-- expect-one-flag -- +{ + "name": "max_items", + "enabled": true, + "value": 99 +} diff --git a/internal/cmd/testdata/script/evaluate-one-feature.txtar b/internal/cmd/testdata/script/evaluate-one-feature.txtar new file mode 100644 index 0000000..e308178 --- /dev/null +++ b/internal/cmd/testdata/script/evaluate-one-feature.txtar @@ -0,0 +1,20 @@ +# Given: an environment resolving two flags +# When: one of them is named +# Then: a detail view of just that one is rendered +exec flagsmith evaluate max_items +cmp stdout expect-detail +! stdout 'onboarding_banner' + +# Given: an environment resolving two flags +# When: a feature it does not resolve is named +# Then: the error quotes the name and points at the listing +! exec flagsmith evaluate nope +stderr '"nope"' +stderr 'Run `flagsmith eval`' + +-- flagsmith.json -- +{"environment": "WqXhZk8sVY3dGgTqZ9pJmN"} +-- expect-detail -- +Feature max_items +Enabled off +Value 25 diff --git a/internal/cmd/testdata/script/evaluate-test-flag.txtar b/internal/cmd/testdata/script/evaluate-test-flag.txtar new file mode 100644 index 0000000..deaad1f --- /dev/null +++ b/internal/cmd/testdata/script/evaluate-test-flag.txtar @@ -0,0 +1,40 @@ +# Given: an environment where max_items is disabled +# When: it is evaluated with --test +# Then: the invocation fails as an ordinary failure, and the environment is not named +! exec flagsmith evaluate max_items --test +stderr 'max_items is disabled' +! stderr 'Usage:' +! stderr 'WqXhZk8sVY3dGgTqZ9pJmN' +cmp stdout expect-detail + +# Given: an environment where onboarding_banner is enabled +# When: it is evaluated with --test +# Then: the flag passes straight through +exec flagsmith evaluate onboarding_banner --test +stdout 'onboarding_banner' + +# Given: an environment where max_items is disabled +# When: --test is combined with --json +# Then: it still fails, and the entry is printed as well +! exec flagsmith evaluate max_items --test --json +cmp stdout expect-json + +# Given: an environment resolving two flags +# When: --test is passed without naming a feature +# Then: it is a usage error naming the flag — there is nothing to test +! exec flagsmith evaluate --test +stderr '--test' +stderr 'Usage:' + +-- flagsmith.json -- +{"environment": "WqXhZk8sVY3dGgTqZ9pJmN"} +-- expect-detail -- +Feature max_items +Enabled off +Value 25 +-- expect-json -- +{ + "name": "max_items", + "enabled": false, + "value": 25 +} diff --git a/internal/cmd/testdata/script/evaluate.txtar b/internal/cmd/testdata/script/evaluate.txtar new file mode 100644 index 0000000..2765ab7 --- /dev/null +++ b/internal/cmd/testdata/script/evaluate.txtar @@ -0,0 +1,94 @@ +# Given: an environment resolving two flags, addressed by its client-side key +# When: evaluated with no arguments +# Then: a table is printed from one SDK API call carrying the CLI's User-Agent, and stderr stays empty +exec flagsmith evaluate +cmp stdout expect-table +! stderr . +dump requests requests.txt +cmpenv requests.txt expect-requests +dump sdk-agents agents.txt +grep '^flagsmith-cli/' agents.txt + +# Given: an environment resolving two flags +# When: the command is spelled `eval` +# Then: it is an alias for evaluate, resolving the environment's two flags +exec flagsmith eval +stdout '2 flags' + +# Given: an environment resolving two flags +# When: evaluated with --json +# Then: an EvaluationResult, with reason, variant and segments omitted rather than invented +exec flagsmith evaluate --json +cmp stdout expect-json +! stdout 'reason' +! stdout 'variant' +! stdout 'segments' + +# Given: an environment resolving two flags +# When: evaluated with --js +# Then: the frontend SDK's hydration state, keyed by name, with no $schema +exec flagsmith evaluate --js +cmpenv stdout expect-js +! stdout '\$schema' + +# Given: an environment resolving two flags +# When: both --js and --json are passed +# Then: it is a usage error — they are different shapes +! exec flagsmith evaluate --json --js +stderr 'different shapes' +stderr 'Usage:' + +# Given: an environment resolving two flags +# When: --js is passed alongside a named feature +# Then: it is a usage error — a one-flag state is a dud +! exec flagsmith evaluate max_items --js +stderr 'Usage:' + +# Given: an environment resolving two flags +# When: --jq is applied to either shape +# Then: it filters whichever shape was asked for +exec flagsmith evaluate --js --jq .flags.max_items.value +stdout '^25$' +exec flagsmith evaluate --jq .flags.max_items.name +stdout '^max_items$' + +-- flagsmith.json -- +{"environment": "WqXhZk8sVY3dGgTqZ9pJmN"} +-- expect-table -- +FEATURE ENABLED VALUE +onboarding_banner on - +max_items off 25 + +2 flags +-- expect-requests -- +GET /api/v1/flags/ [environment-key] +-- expect-json -- +{ + "$schema": "https://raw.githubusercontent.com/Flagsmith/flagsmith/main/sdk/evaluation-result.json", + "flags": { + "max_items": { + "name": "max_items", + "enabled": false, + "value": 25 + }, + "onboarding_banner": { + "name": "onboarding_banner", + "enabled": true, + "value": null + } + } +} +-- expect-js -- +{ + "api": "$API/api/v1/", + "flags": { + "max_items": { + "enabled": false, + "value": 25 + }, + "onboarding_banner": { + "enabled": true, + "value": null + } + } +} diff --git a/internal/cmd/testdata/script/feature-search.txtar b/internal/cmd/testdata/script/feature-search.txtar new file mode 100644 index 0000000..c084d37 --- /dev/null +++ b/internal/cmd/testdata/script/feature-search.txtar @@ -0,0 +1,65 @@ +# Single-feature commands narrow the features fetch server-side with a search +# filter — a contains match on the name — and keep the exact match client-side. + +# Given: two features whose names share a prefix +# When: the shorter one's flag is fetched by name +# Then: the fetch is narrowed by that name, and the exact one is what comes back +fake features prefixed.json +exec flagsmith flag get checkout +stdout 'checkout' +! stdout 'checkout_v2' +dump requests requests.txt +grep 'features/.*search=checkout&' requests.txt + +# Given: a flag named by its id rather than by name +# When: it is fetched +# Then: no search is sent — an id cannot match a name search +fake features-default +fake forget-requests +exec flagsmith flag get 2 +dump requests requests.txt +! grep 'search=' requests.txt + +# Given: a feature named rather than numbered +# When: it is fetched +# Then: the fetch is narrowed by that name +fake features catalogue.json +fake forget-requests +exec flagsmith feature get banner-copy +dump requests requests.txt +grep 'search=banner-copy' requests.txt + +# Given: a flag named rather than numbered +# When: it is enabled +# Then: the fetch behind the write is narrowed by that name +fake features-default +fake forget-requests +exec flagsmith flag enable max_items --yes +dump requests requests.txt +grep 'search=max_items' requests.txt + +# Given: FLAGSMITH_JSON_OUTPUT set to a falsey value +# When: a listing runs +# Then: the human table is printed — a falsey value switches the behaviour off rather than merely being set +env FLAGSMITH_JSON_OUTPUT=0 +exec flagsmith organisation list +stdout 'NAME' +! stdout '^\[' + +-- flagsmith.json -- +{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN"} +-- prefixed.json -- +[ + {"id": 7, "name": "checkout", "type": "STANDARD", + "num_segment_overrides": 0, "num_identity_overrides": 0, "code_references_counts": [], + "environment_feature_state": {"enabled": true, "feature_state_value": null}}, + {"id": 8, "name": "checkout_v2", "type": "STANDARD", + "num_segment_overrides": 0, "num_identity_overrides": 0, "code_references_counts": [], + "environment_feature_state": {"enabled": false, "feature_state_value": null}} +] +-- catalogue.json -- +[ + {"id": 91, "name": "banner-copy", "type": "MULTIVARIATE", "description": "A/B banner text", + "initial_value": "hello", "default_enabled": false, "is_archived": false, + "multivariate_options": []} +] diff --git a/internal/cmd/testdata/script/feature-variant.txtar b/internal/cmd/testdata/script/feature-variant.txtar new file mode 100644 index 0000000..761b7ed --- /dev/null +++ b/internal/cmd/testdata/script/feature-variant.txtar @@ -0,0 +1,69 @@ +# Given: a multivariate feature with two variants +# When: they are listed +# Then: each variant's value, weight, key and id are shown +fake features catalogue.json +exec flagsmith feature variant list banner-copy +cmp stdout expect-list + +# Given: a multivariate feature, and a string variant to add to it +# When: it is added +# Then: the value is typed as a string, carrying its weight, key and feature, and the addition is confirmed +fake features catalogue.json +exec flagsmith feature variant add banner-copy --value cta --weight 20 --key button +stderr 'Added variant cta' +dump requests requests.txt +grep '"type":"unicode","string_value":"cta","default_percentage_allocation":20,"key":"button","feature":91' requests.txt + +# Given: a multivariate feature, and a variant whose value looks like a number +# When: it is added +# Then: the value is typed as an integer +fake features catalogue.json +exec flagsmith feature variant add banner-copy --value 42 --weight 10 +dump requests requests.txt +grep '"type":"int","integer_value":42' requests.txt + +# Given: a variant identified by its key +# When: only its weight is changed +# Then: the weight is sent and the value left alone +fake features catalogue.json +fake forget-requests +exec flagsmith feature variant update banner-copy hero --weight 40 +dump requests requests.txt +grep '"default_percentage_allocation":40' requests.txt +! grep '"string_value"' requests.txt + +# Given: a variant identified by its id +# When: it is deleted +# Then: the deletion is confirmed and the variant is gone +fake features catalogue.json +exec flagsmith feature variant delete banner-copy 201 --yes +stderr 'Deleted variant' +exec flagsmith feature variant list banner-copy +! stdout 'headline' +stdout 'subhead' + +# Given: a variant reference the feature does not have +# When: it is deleted +# Then: it fails, naming what was asked for +fake features catalogue.json +! exec flagsmith feature variant delete banner-copy nope --yes +stderr 'nope' + +-- flagsmith.json -- +{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN"} +-- catalogue.json -- +[ + {"id": 88, "name": "checkout-v2", "type": "STANDARD", "description": "New checkout flow", + "initial_value": "green", "default_enabled": true, "is_archived": false, + "multivariate_options": []}, + {"id": 91, "name": "banner-copy", "type": "MULTIVARIATE", "description": "A/B banner text", + "initial_value": "hello", "default_enabled": false, "is_archived": false, + "multivariate_options": [ + {"id": 201, "type": "unicode", "string_value": "headline", "default_percentage_allocation": 30, "key": "hero"}, + {"id": 202, "type": "unicode", "string_value": "subhead", "default_percentage_allocation": 70, "key": "sub"} + ]} +] +-- expect-list -- +VALUE WEIGHT KEY ID +headline 30 hero 201 +subhead 70 sub 202 diff --git a/internal/cmd/testdata/script/feature-write.txtar b/internal/cmd/testdata/script/feature-write.txtar new file mode 100644 index 0000000..b5e118b --- /dev/null +++ b/internal/cmd/testdata/script/feature-write.txtar @@ -0,0 +1,99 @@ +# Given: a project to add a feature to +# When: one is created with a default value, description and --enabled +# Then: each reaches the request body, and the creation is confirmed +fake features catalogue.json +exec flagsmith feature create checkout-3 --value blue --description d --enabled +stderr 'Created feature checkout-3' +dump requests requests.txt +grep '"name":"checkout-3","description":"d","initial_value":"blue","default_enabled":true' requests.txt + +# Given: a project to add a feature to +# When: its default is given as --default-value rather than --value +# Then: it is set as the feature's initial value, as --value would +fake features catalogue.json +exec flagsmith feature create aliased --default-value teal +dump requests requests.txt +grep '"initial_value":"teal"' requests.txt + +# Given: variants written inline, one a string and one a number +# When: a multivariate feature is created from them +# Then: each variant is typed by its JSON value, carrying its weight +fake features catalogue.json +exec flagsmith feature create banner-2 --value hello --variants '[{"value":"a","weight":30},{"value":42,"weight":70}]' +dump requests requests.txt +grep '"type":"unicode","string_value":"a","default_percentage_allocation":30' requests.txt +grep '"type":"int","integer_value":42,"default_percentage_allocation":70' requests.txt + +# Given: an existing feature +# When: its description is changed +# Then: the new description reaches the request body +fake features catalogue.json +exec flagsmith feature update checkout-v2 --description redesign +dump requests requests.txt +grep '"description":"redesign"' requests.txt + +# Given: a live feature and an archived one +# When: one is archived and the other unarchived +# Then: each write carries the archive flag it was asked for +fake features catalogue.json +fake forget-requests +exec flagsmith feature update checkout-v2 --archive +exec flagsmith feature update legacy-copy --unarchive +dump requests requests.txt +grep '"is_archived":true' requests.txt +grep '"is_archived":false' requests.txt + +# Given: a feature to be archived and unarchived at once +# When: both flags are passed +# Then: it is a usage error +! exec flagsmith feature update checkout-v2 --archive --unarchive +stderr 'Usage:' + +# Given: a feature named checkout-v2 with id 88 +# When: it is deleted with --yes +# Then: the deletion is confirmed and the feature is gone +fake features catalogue.json +exec flagsmith feature delete checkout-v2 --yes +stderr 'Deleted feature checkout-v2 \(88\)' +exec flagsmith feature list +! stdout 'checkout-v2' + +# Given: a feature, and FLAGSMITH_NO_INPUT set +# When: it is deleted without --yes +# Then: it is refused naming --yes, and the feature survives +fake features catalogue.json +env FLAGSMITH_NO_INPUT=1 +! exec flagsmith feature delete checkout-v2 +stderr '--yes' +stderr 'Usage:' +exec flagsmith feature list +stdout 'checkout-v2' + +# Given: a feature, and --no-input on the command line +# When: it is deleted without --yes +# Then: it is refused naming --yes, and the feature survives +fake features catalogue.json +env FLAGSMITH_NO_INPUT= +! exec flagsmith feature delete checkout-v2 --no-input +stderr '--yes' +stderr 'Usage:' +exec flagsmith feature list +stdout 'checkout-v2' + +-- flagsmith.json -- +{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN"} +-- catalogue.json -- +[ + {"id": 88, "name": "checkout-v2", "type": "STANDARD", "description": "New checkout flow", + "initial_value": "green", "default_enabled": true, "is_archived": false, + "multivariate_options": []}, + {"id": 91, "name": "banner-copy", "type": "MULTIVARIATE", "description": "A/B banner text", + "initial_value": "hello", "default_enabled": false, "is_archived": false, + "multivariate_options": [ + {"id": 201, "type": "unicode", "string_value": "headline", "default_percentage_allocation": 30, "key": "hero"}, + {"id": 202, "type": "unicode", "string_value": "subhead", "default_percentage_allocation": 70, "key": "sub"} + ]}, + {"id": 40, "name": "legacy-copy", "type": "STANDARD", "description": "Retired", + "initial_value": "old", "default_enabled": false, "is_archived": true, + "multivariate_options": []} +] diff --git a/internal/cmd/testdata/script/feature.txtar b/internal/cmd/testdata/script/feature.txtar new file mode 100644 index 0000000..b516336 --- /dev/null +++ b/internal/cmd/testdata/script/feature.txtar @@ -0,0 +1,96 @@ +# Given: a project with two live features and one archived +# When: the features are listed +# Then: the archived one is filtered out server-side, and the rest are shown with their defaults +fake features catalogue.json +exec flagsmith feature list +cmp stdout expect-list +dump requests requests.txt +grep 'features/.*is_archived=false' requests.txt + +# Given: a project with two live features and one archived +# When: they are listed with --include-archived +# Then: no archive filter is sent, and the archived one appears +fake features catalogue.json +fake forget-requests +exec flagsmith feature list --include-archived +stdout 'legacy-copy' +stdout '3 features' +dump requests requests.txt +! grep 'is_archived=' requests.txt + +# Given: a feature whose default value is longer than a column +# When: the features are listed +# Then: the value is truncated rather than wrapped +fake features long-value.json +exec flagsmith feature list +stdout '…' +! stdout 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' + +# Given: a multivariate feature with two variants +# When: it is fetched by name +# Then: the detail shows its type, default and each variant's value, weight and key +fake features catalogue.json +exec flagsmith feature get banner-copy +cmp stdout expect-get + +# Given: a multivariate feature with two variants +# When: it is fetched by id as JSON +# Then: the curated shape names the type, default value and the variants +fake features catalogue.json +exec flagsmith feature get 91 --json --jq '.type, .default_value, .variants[0].value, .variants[0].weight, .variants[0].key' +cmp stdout expect-get-json + +# Given: a standard feature, which has no variants at all +# When: its variants are listed as JSON +# Then: the empty list renders as [] rather than null, so --jq can iterate it +fake features catalogue.json +exec flagsmith feature variant list checkout-v2 --json +stdout '^\[\]$' +exec flagsmith feature variant list checkout-v2 --jq .[] +! stdout . + +-- flagsmith.json -- +{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN"} +-- catalogue.json -- +[ + {"id": 88, "name": "checkout-v2", "type": "STANDARD", "description": "New checkout flow", + "initial_value": "green", "default_enabled": true, "is_archived": false, + "multivariate_options": []}, + {"id": 91, "name": "banner-copy", "type": "MULTIVARIATE", "description": "A/B banner text", + "initial_value": "hello", "default_enabled": false, "is_archived": false, + "multivariate_options": [ + {"id": 201, "type": "unicode", "string_value": "headline", "default_percentage_allocation": 30, "key": "hero"}, + {"id": 202, "type": "unicode", "string_value": "subhead", "default_percentage_allocation": 70, "key": "sub"} + ]}, + {"id": 40, "name": "legacy-copy", "type": "STANDARD", "description": "Retired", + "initial_value": "old", "default_enabled": false, "is_archived": true, + "multivariate_options": []} +] +-- long-value.json -- +[ + {"id": 1, "name": "blob", "type": "STANDARD", "is_archived": false, "multivariate_options": [], + "initial_value": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"} +] +-- expect-list -- +NAME ID TYPE DEFAULT VALUE DESCRIPTION +checkout-v2 88 standard green New checkout flow +banner-copy 91 multivariate hello A/B banner text + +2 features +-- expect-get -- +Feature banner-copy (91) +Description A/B banner text +Type multivariate +Default value hello +Enabled false + +Variants + VALUE WEIGHT KEY ID + headline 30 hero 201 + subhead 70 sub 202 +-- expect-get-json -- +multivariate +hello +headline +30 +hero diff --git a/internal/cmd/testdata/script/flag-by-id.txtar b/internal/cmd/testdata/script/flag-by-id.txtar new file mode 100644 index 0000000..e2ecb59 --- /dev/null +++ b/internal/cmd/testdata/script/flag-by-id.txtar @@ -0,0 +1,34 @@ +# A feature can be named by its id wherever it can be named by name, and the +# canonical name is what travels and what is printed back. + +# Given: a flag named max_items with id 2 +# When: it is fetched by that id +# Then: the flag is resolved and shown by name +exec flagsmith flag get 2 +stdout 'max_items' + +# Given: a flag named max_items with id 2 +# When: it is enabled by that id +# Then: the canonical name goes to the wire and comes back in the confirmation +exec flagsmith flag update 2 --enable --yes +stderr 'Enabled max_items' +dump requests requests.txt +grep '"feature":\{"name":"max_items"\}' requests.txt + +# Given: a flag with an override for segment 12, named by id +# When: that override is deleted +# Then: the feature is targeted by id on the wire +exec flagsmith flag delete 2 --segment 12 --yes +dump requests requests.txt +grep '"feature":\{"id":2\}' requests.txt + +# Given: a flag overridden for two segments, named by id +# When: those overrides are reordered +# Then: the canonical name goes to the wire +fake feature-overrides +exec flagsmith flag reorder 2 us-adults beta-optin --yes +dump requests requests.txt +grep '"feature":\{"name":"max_items"\}' requests.txt + +-- flagsmith.json -- +{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN"} diff --git a/internal/cmd/testdata/script/flag-delete.txtar b/internal/cmd/testdata/script/flag-delete.txtar new file mode 100644 index 0000000..7ee887d --- /dev/null +++ b/internal/cmd/testdata/script/flag-delete.txtar @@ -0,0 +1,27 @@ +# Given: a flag with an override for segment 12 +# When: that override is deleted +# Then: the delete names the feature and segment, and the deletion is confirmed +exec flagsmith flag delete max_items --segment 12 --yes +stderr 'Deleted max_items override for segment 12' +dump requests requests.txt +grep '"feature":\{"name":"max_items"\},"segment":\{"id":12\}' requests.txt + +# Given: a flag and no override target +# When: a delete is attempted +# Then: it is a usage error naming --segment, and nothing is deleted +fake forget-requests +! exec flagsmith flag delete max_items --yes +stderr '--segment' +stderr 'Usage:' +dump requests requests.txt +! grep 'delete-segment-override' requests.txt + +# Given: an environment where the override being deleted does not exist +# When: it is deleted +# Then: it fails, naming the segment that has none +fake segment-override-missing +! exec flagsmith flag delete max_items --segment 99 --yes +stderr 'segment 99' + +-- flagsmith.json -- +{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN"} diff --git a/internal/cmd/testdata/script/flag-enable-disable.txtar b/internal/cmd/testdata/script/flag-enable-disable.txtar new file mode 100644 index 0000000..f766572 --- /dev/null +++ b/internal/cmd/testdata/script/flag-enable-disable.txtar @@ -0,0 +1,44 @@ +# Given: max_items, an integer flag set to 25 and turned off +# When: `flag enable` turns it on +# Then: the value is carried into the write, and the new state is rendered from that one fetch +fake forget-requests +exec flagsmith flag enable max_items --yes +stderr 'Enabled max_items' +stdout 'on' +stdout '25' +dump requests requests.txt +grep -count=1 'GET /api/v1/projects/101/features/' requests.txt +grep '"environment_default":\{"enabled":true,"value":\{"type":"integer","value":"25"\}\}' requests.txt + +# Given: max_items, an integer flag set to 25 and turned on +# When: `flag disable` turns it off +# Then: the write says so, and so does the confirmation +exec flagsmith flag disable max_items --yes +stderr 'Disabled max_items' +dump requests requests.txt +grep '"environment_default":\{"enabled":false' requests.txt + +# Given: a flag with no override for segment 7 +# When: `flag enable` targets that segment +# Then: the write carries a segment override for 7, turned on +fake segment-override off +exec flagsmith flag enable max_items --segment 7 --yes +dump requests requests.txt +grep '"segment_overrides":\[\{"segment_id":7,"enabled":true' requests.txt + +# Given: a flag, a segment and an identity named at once +# When: both --segment and --identifier are passed +# Then: it is a usage error — an override belongs to one or the other +! exec flagsmith flag enable max_items --segment 7 --identifier u1 --yes +stderr 'Usage:' + +# Given: an environment whose updates go through change-request workflows +# When: a flag is enabled +# Then: it fails saying so, pointing at the documentation for it +fake workflow-gated +! exec flagsmith flag update max_items --enable --yes +stderr 'change-request workflows' +stderr 'docs.flagsmith.com/advanced-use/change-requests' + +-- flagsmith.json -- +{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN"} diff --git a/internal/cmd/testdata/script/flag-get.txtar b/internal/cmd/testdata/script/flag-get.txtar new file mode 100644 index 0000000..553c331 --- /dev/null +++ b/internal/cmd/testdata/script/flag-get.txtar @@ -0,0 +1,128 @@ +# Given: a flag with segment, identity and code-reference counts on it +# When: it is fetched +# Then: the detail view shows its state, value and each of those counts +exec flagsmith flag get max_items +cmp stdout expect-detail + +# Given: a flag with segment, identity and code-reference counts on it +# When: it is fetched as JSON +# Then: the curated fields sit at the top level, with no nested state left behind +exec flagsmith flag get max_items --json --jq '.feature, .type, .enabled, .value, .segment_overrides, .identity_overrides, .code_references, has("environment_feature_state")' +cmp stdout expect-json + +# Given: two flags whose names share a prefix, asked for in the wrong case +# When: the shorter name is fetched +# Then: the match is exact and case-insensitive, not a prefix of the longer one +fake features prefixed.json +exec flagsmith flag get CheckOut +stdout 'checkout' +! stdout 'checkout-v2' + +# Given: a name no flag in the environment has +# When: it is fetched +# Then: it fails, naming what was asked for +! exec flagsmith flag get ghost +stderr 'ghost' + +# Given: a flag whose identity-override count the API left null +# When: it is fetched +# Then: the count reads 0 rather than nothing at all +fake features null-counts.json +exec flagsmith flag get edgeflag +cmp stdout expect-null-counts + +# Given: a flag overridden for segment 12 +# When: that override is fetched +# Then: the override's own state and value are shown, not the environment default +fake segment-override on +exec flagsmith flag get max_items --segment 12 +stdout 'special' +stdout 'on' +dump requests requests.txt +grep 'GET /api/v1/projects/101/features/.*segment=12' requests.txt + +# Given: a flag with no override for segment 99 +# When: that override is fetched +# Then: it fails, naming the segment that has none +fake segment-override off +! exec flagsmith flag get max_items --segment 99 +stderr 'segment 99' + +# Given: a segment override whose priority and segment name the API knows +# When: it is fetched +# Then: the view carries the priority and names the segment +fake segment-override on +fake feature-segments 2 override-meta.json +exec flagsmith flag get max_items --segment 12 +stdout 'Priority' +stdout 'powerusers \(12\)' + +# Given: a segment override whose priority and segment name the API knows +# When: it is fetched as JSON +# Then: the segment is an object of id and name, alongside the priority +fake segment-override on +fake feature-segments 2 override-meta.json +exec flagsmith flag get max_items --segment 12 --json --jq '.segment.id, .segment.name, .priority' +cmp stdout expect-segment-meta + +# Given: a segment override the API has no priority metadata for +# When: it is fetched +# Then: the view degrades to the bare id rather than inventing a name +fake segment-override on +fake feature-segments 2 no-meta.json +exec flagsmith flag get max_items --segment 12 +stdout '12' +! stdout '\(12\)' + +-- flagsmith.json -- +{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN"} +-- prefixed.json -- +[ + {"id": 1, "name": "checkout", "type": "STANDARD", + "environment_feature_state": {"enabled": true, "feature_state_value": "a"}}, + {"id": 2, "name": "checkout-v2", "type": "STANDARD", + "environment_feature_state": {"enabled": false, "feature_state_value": "b"}} +] +-- null-counts.json -- +[ + {"id": 1, "name": "edgeflag", "type": "STANDARD", + "num_segment_overrides": 0, "num_identity_overrides": null, + "environment_feature_state": {"enabled": true, "feature_state_value": "x"}} +] +-- override-meta.json -- +[{"id": 1200, "segment": 12, "segment_name": "powerusers", "priority": 1, "environment": 1}] +-- no-meta.json -- +[] +-- expect-detail -- +Feature max_items +Description +Type standard +State off +Value 25 +Segment overrides 1 +Identity overrides 2 +Code references 3 +Lifecycle stage - +-- expect-json -- +max_items +standard +false +25 +1 +2 +3 +false +-- expect-null-counts -- +Feature edgeflag +Description +Type standard +State on +Value x +Segment overrides 0 +Identity overrides 0 +Code references 0 +Lifecycle stage - +-- expect-segment-meta -- +12 +powerusers +1 diff --git a/internal/cmd/testdata/script/flag-identity-core.txtar b/internal/cmd/testdata/script/flag-identity-core.txtar new file mode 100644 index 0000000..0d78851 --- /dev/null +++ b/internal/cmd/testdata/script/flag-identity-core.txtar @@ -0,0 +1,106 @@ +# Identity overrides on a project that keeps its identities in the core API. +# max_items is feature 2; user-1 is an identity the environment already knows. + +# Given: a flag and an identity the environment knows +# When: an override is set with -i +# Then: -i is the identifier, and the write targets that identity's feature state +exec flagsmith flag update max_items -i user-1 --value 42 --yes +dump requests requests.txt +grep '"feature":2,.*"feature_state_value":42' requests.txt + +# Given: an identity with an override on a flag +# When: it is fetched and then deleted with -i +# Then: -i is the identifier for both +fake identity-overrides user-1-override.json +exec flagsmith flag get max_items -i user-1 +stdout 'user-1' +stdout '7' +exec flagsmith flag delete max_items -i user-1 --yes + +# Given: a flag and an identity the environment knows +# When: an override is set with --identifier +# Then: the core endpoint carries it, the edge endpoint is untouched, and the change is confirmed +fake features-default +fake forget-requests +exec flagsmith flag update max_items --identifier user-1 --value 42 --yes +stderr 'Set max_items to 42 for identifier user-1' +dump requests requests.txt +grep '"enabled":false,"feature":2,"feature_state_value":42' requests.txt +! grep 'edge-identities' requests.txt + +# Given: an identity whose override on a flag is on at "custom" +# When: that override is fetched +# Then: the detail names the identifier and shows the override's own state +fake identity-overrides user-1-custom.json +exec flagsmith flag get max_items --identifier user-1 +stdout 'Identifier' +stdout 'user-1' +stdout 'custom' +stdout 'on' + +# Given: an identity with an override on a flag +# When: that override is deleted +# Then: the deletion is confirmed and the override is gone +fake identity-overrides user-1-custom.json +exec flagsmith flag delete max_items --identifier user-1 --yes +stderr 'Deleted max_items override for identifier user-1' +! exec flagsmith flag get max_items --identifier user-1 + +# Given: an identifier the environment has never seen, and no --yes to confirm with +# When: its override is deleted +# Then: the missing identity is reported rather than a confirmation being demanded first +! exec flagsmith flag delete max_items --identifier ghost +stderr 'identity "ghost" not found' +! stderr '\-\-yes' + +# Given: an identity whose override on a flag is off at "x" +# When: it is enabled +# Then: the identifier is resolved once, and the updated detail is reprinted +fake identity-overrides user-1-off.json +fake forget-requests +exec flagsmith flag update max_items --identifier user-1 --enable --yes +stdout 'on' +stdout 'x' +dump requests requests.txt +grep -count=1 'GET /api/v1/environments/WqXhZk8sVY3dGgTqZ9pJmN/identities/\?q=' requests.txt + +# Given: a flag turned on, and an identity with no override for it +# When: an override is created with only a value +# Then: it inherits the environment default's state +fake features-default +exec flagsmith flag update onboarding_banner --identifier user-1 --value yo --yes +dump requests requests.txt +grep '"enabled":true,"feature":1,"feature_state_value":"yo"' requests.txt + +# Given: an identifier the environment has never seen +# When: an override is set for it +# Then: the identity is created after one lookup, and the override written against it +fake features-default +fake forget-requests +exec flagsmith flag update max_items --identifier new-user --enable --yes +dump requests requests.txt +grep -count=1 'GET /api/v1/environments/WqXhZk8sVY3dGgTqZ9pJmN/identities/\?q=' requests.txt +grep '"enabled":true,"feature":2' requests.txt + +# Given: a flag, a segment and an identity named at once +# When: both --segment and --identifier are passed +# Then: it is a usage error — an override belongs to one or the other +! exec flagsmith flag update max_items --segment 1 --identifier user-1 --yes +stderr 'mutually exclusive' +stderr 'Usage:' + +# Given: a flag and no override target at all +# When: a delete is attempted +# Then: it is a usage error naming the targets it could have had +! exec flagsmith flag delete max_items --yes +stderr '--identifier' +stderr 'Usage:' + +-- flagsmith.json -- +{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN"} +-- user-1-override.json -- +[{"identifier": "user-1", "id": 501, "feature": 2, "enabled": true, "value": 7}] +-- user-1-custom.json -- +[{"identifier": "user-1", "id": 501, "feature": 2, "enabled": true, "value": "custom"}] +-- user-1-off.json -- +[{"identifier": "user-1", "id": 501, "feature": 2, "enabled": false, "value": "x"}] diff --git a/internal/cmd/testdata/script/flag-identity-edge.txtar b/internal/cmd/testdata/script/flag-identity-edge.txtar new file mode 100644 index 0000000..6273b89 --- /dev/null +++ b/internal/cmd/testdata/script/flag-identity-edge.txtar @@ -0,0 +1,49 @@ +# The same overrides on a project that keeps its identities at the edge, where +# they are addressed by identifier rather than by a core identity id. +fake edge-identities + +# Given: an edge project and an identity with no override on a flag +# When: an override is set for it +# Then: the edge endpoint carries it, the core endpoint is untouched, and both changes are confirmed +exec flagsmith flag update max_items --identifier edge-user --enable --value 7 --yes +stderr 'Set max_items to 7 for identifier edge-user' +stderr 'Enabled max_items for identifier edge-user' +dump requests requests.txt +grep '"enabled":true,"feature":2,"feature_state_value":7,"identifier":"edge-user"' requests.txt +! grep 'identities/501/featurestates' requests.txt + +# Given: an edge project and an identity whose override on a flag is off at "e" +# When: that override is fetched +# Then: the uuid is resolved and the override's own state shown +fake edge-overrides edge-user-off.json +exec flagsmith flag get max_items --identifier edge-user +stdout 'Identifier' +stdout 'edge-user' +stdout 'e' +stdout 'off' + +# Given: an edge project and an identity whose override on a flag is on at "e" +# When: that override is deleted +# Then: the delete names the identifier and feature, and the override is gone +fake edge-overrides edge-user-on.json +exec flagsmith flag delete max_items --identifier edge-user --yes +stderr 'Deleted max_items override for identifier edge-user' +dump requests requests.txt +grep '"feature":2,"identifier":"edge-user"' requests.txt + +# Given: an edge project and an identity whose override on a flag is off at "e" +# When: it is enabled +# Then: the uuid is resolved once, and the updated detail is reprinted +fake edge-overrides edge-user-off.json +fake forget-requests +exec flagsmith flag update max_items --identifier edge-user --enable --yes +stdout 'on' +dump requests requests.txt +grep -count=1 'GET /api/v1/environments/WqXhZk8sVY3dGgTqZ9pJmN/edge-identities/\?q=' requests.txt + +-- flagsmith.json -- +{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN"} +-- edge-user-off.json -- +[{"identifier": "edge-user", "feature": 2, "enabled": false, "value": "e"}] +-- edge-user-on.json -- +[{"identifier": "edge-user", "feature": 2, "enabled": true, "value": "e"}] diff --git a/internal/cmd/testdata/script/flag-list-environment-name.txtar b/internal/cmd/testdata/script/flag-list-environment-name.txtar new file mode 100644 index 0000000..07fbcb7 --- /dev/null +++ b/internal/cmd/testdata/script/flag-list-environment-name.txtar @@ -0,0 +1,46 @@ +# Given: a config naming its environment by name +# When: the flags are listed +# Then: the name is resolved to its id before the features are asked for +exec flagsmith flag list +dump requests requests.txt +grep 'features/\?environment=1' requests.txt + +# Given: a config naming its environment by key +# When: the flags are listed +# Then: that environment's id is the one the features are asked for +exec flagsmith flag list -c by-production-key.json +dump requests requests.txt +grep 'features/\?environment=2' requests.txt + +# Given: a config naming its environment by name, and no credentials +# When: the flags are listed +# Then: it fails saying so — a name cannot be resolved without one +env $API_KEY_VAR= +! exec flagsmith flag list +stderr 'not logged in' +stderr 'Run `flagsmith login`' + +# Given: two environments in one project sharing a name, and no terminal to choose on +# When: the flags are listed for that name +# Then: it is a usage error, offering the key as the way to say which +env $API_KEY_VAR=$MASTER_KEY +fake environments-named Staging=stagingKeyA0000000000 Staging=stagingKeyB0000000000 +! exec flagsmith flag list -c by-staging.json +stderr 'use its key instead' +stderr 'Usage:' + +# Given: a config naming an environment the project does not have +# When: the flags are listed +# Then: it fails, offering the command that would list the real ones +fake environments +! exec flagsmith flag list -c by-unknown.json +stderr 'flagsmith environment list' + +-- flagsmith.json -- +{"project": 101, "environment": "Development"} +-- by-production-key.json -- +{"project": 101, "environment": "K2mVsGdXhZ8kQqZ9pJmNbJ"} +-- by-staging.json -- +{"project": 101, "environment": "Staging"} +-- by-unknown.json -- +{"project": 101, "environment": "Nope"} diff --git a/internal/cmd/testdata/script/flag-list-overrides.txtar b/internal/cmd/testdata/script/flag-list-overrides.txtar new file mode 100644 index 0000000..d402eb9 --- /dev/null +++ b/internal/cmd/testdata/script/flag-list-overrides.txtar @@ -0,0 +1,154 @@ +# Given: a segment with one flag overridden for it +# When: the flags are listed for that segment +# Then: only the overridden flag is shown, with the override's own value and no lifecycle column +fake segment-override on +exec flagsmith flag list --segment 12 +cmp stdout expect-segment-list +dump requests requests.txt +grep 'features/\?environment=1&segment=12' requests.txt + +# Given: a segment override carrying its priority and the segment's name +# When: the flags are listed for that segment as JSON +# Then: the segment is an object of id and name, alongside the priority +fake segment-override on +fake feature-segments 2 one-override.json +exec flagsmith flag list --segment 12 --json --jq '.[0] | .feature, .enabled, .segment.id, .segment.name, .priority' +cmp stdout expect-segment-json + +# Given: a segment nothing is overridden for +# When: the flags are listed for it +# Then: that is said plainly +fake segment-override off +exec flagsmith flag list --segment 99 +stdout 'No segment overrides' + +# Given: a flag overridden for two segments, at priorities 0 and 1 +# When: its overrides are listed +# Then: they are shown in priority order, and the environment default is not one of them +fake feature-overrides +exec flagsmith flag list --feature max_items +cmp stdout expect-feature-overrides + +# Given: a flag overridden for two segments, one holding a string and one an integer +# When: its overrides are listed as JSON +# Then: each carries its segment and priority, and the typed values arrive as scalars +fake feature-overrides +exec flagsmith flag list --feature max_items --json --jq '.[0] | .feature, .priority, .segment.id, .segment.name, .enabled, .value' +cmp stdout expect-first-override +exec flagsmith flag list --feature max_items --json --jq '.[1] | .enabled, .value' +cmp stdout expect-second-override + +# Given: a flag overridden for two segments +# When: --feature names it by id rather than by name +# Then: its two segment overrides are listed +fake feature-overrides +exec flagsmith flag list --feature 2 +stdout 'beta-optin' + +# Given: a flag with no segment overrides at all +# When: its overrides are listed +# Then: that is said plainly +fake segment-override on +fake feature-segments 2 no-overrides.json +exec flagsmith flag list --feature max_items +stdout 'No segment overrides' + +# Given: a listing asked to narrow by a feature and by a segment at once +# When: both are passed +# Then: it is a usage error — they are different listings +! exec flagsmith flag list --feature max_items --segment 12 +stderr 'mutually exclusive' +stderr 'Usage:' + +# Given: a feature name the project does not have +# When: its overrides are listed +# Then: it fails, offering the listing that would show the real names +! exec flagsmith flag list --feature ghost +stderr 'ghost' +stderr 'Run `flagsmith flag list`' + +# Given: a flag overridden for two identities +# When: its identity overrides are listed +# Then: each identifier is shown with the value it resolves +fake segment-override on +fake identity-overrides two-identities.json +exec flagsmith flag list --feature max_items --identities +cmp stdout expect-identity-list + +# Given: a flag overridden for one identity +# When: its identity overrides are listed as JSON +# Then: the entry names the identifier, the feature and the resolved state +fake segment-override on +fake identity-overrides one-identity.json +exec flagsmith flag list --feature max_items --identities --json --jq '.[0] | .identifier, .feature, .enabled, .value' +cmp stdout expect-identity-json + +# Given: a project that keeps its identities at the edge +# When: a flag's identity overrides are listed +# Then: the edge endpoint answers, and its override is listed +fake segment-override on +fake edge-overrides edge-identity.json +exec flagsmith flag list --feature max_items --identities +stdout 'edge-user' +stdout '1 override' + +# Given: a request for identity overrides that names no feature +# When: --identities is passed on its own +# Then: it is a usage error naming the flag that is missing +! exec flagsmith flag list --identities +stderr '--feature' +stderr 'Usage:' + +-- flagsmith.json -- +{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN"} +-- no-overrides.json -- +[] +-- one-override.json -- +[{"id": 1200, "segment": 12, "segment_name": "powerusers", "priority": 1, "environment": 1}] +-- two-identities.json -- +[ + {"identifier": "id-123", "id": 501, "feature": 2, "enabled": true, "value": "hero"}, + {"identifier": "id-456", "id": 502, "feature": 2, "enabled": false, "value": "hello"} +] +-- one-identity.json -- +[{"identifier": "id-123", "id": 501, "feature": 2, "enabled": true, "value": "hero"}] +-- edge-identity.json -- +[{"identifier": "edge-user", "feature": 2, "enabled": true, "value": "x"}] +-- expect-segment-list -- +NAME TYPE STATE VALUE +max_items standard on special + +1 flag +-- expect-segment-json -- +max_items +true +12 +powerusers +1 +-- expect-feature-overrides -- +PRIORITY SEGMENT STATE VALUE +0 beta-optin (57) on blue +1 us-adults (42) off 25 + +2 overrides +-- expect-first-override -- +max_items +0 +57 +beta-optin +true +blue +-- expect-second-override -- +false +25 +-- expect-identity-list -- +IDENTIFIER STATE VALUE +id-123 on hero +id-456 off hello + +2 overrides +-- expect-identity-json -- +id-123 +max_items +true +hero diff --git a/internal/cmd/testdata/script/flag-list.txtar b/internal/cmd/testdata/script/flag-list.txtar new file mode 100644 index 0000000..451f7dc --- /dev/null +++ b/internal/cmd/testdata/script/flag-list.txtar @@ -0,0 +1,96 @@ +# Given: an environment resolving two flags +# When: they are listed +# Then: a table of their state and value is printed, and counted +exec flagsmith flag list +cmp stdout expect-list + +# Given: an environment resolving two flags +# When: they are listed as JSON +# Then: the state is hoisted to the top level and the raw nested state dropped +exec flagsmith flag list --json --jq '.[0] | .feature, .enabled, has("environment_feature_state"), has("lifecycle_stage")' +cmp stdout expect-json-shape + +# Given: a project with no features in it +# When: its flags are listed +# Then: that is said plainly rather than printing an empty table +fake features empty.json +exec flagsmith flag list +stdout 'No flags' + +# Given: a config naming no environment, and an environment key in the setting +# When: the flags are listed +# Then: the key is enough on its own +fake features default.json +env $SDK_KEY_VAR=WqXhZk8sVY3dGgTqZ9pJmN +exec flagsmith flag list -c no-environment.json +stdout 'onboarding_banner' + +# Given: a config naming no environment, and no environment key anywhere +# When: the flags are listed +# Then: it fails, offering both the flag and the command that would set one +env $SDK_KEY_VAR= +! exec flagsmith flag list -c no-environment.json +stderr 'environment' +stderr '\-e' +stderr 'flagsmith init' + +# Given: a disabled multivariate flag whose value is longer than a column +# When: the flags are listed +# Then: the row says so, and the value is truncated rather than wrapped +fake features long-value.json +exec flagsmith flag list +cmp stdout expect-long-value + +# Given: a flag whose value is a JSON document written across several lines +# When: the flags are listed +# Then: the value is flattened onto its own row rather than spilling over the table +fake features multiline-value.json +exec flagsmith flag list +cmp stdout expect-multiline-value + +-- flagsmith.json -- +{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN"} +-- no-environment.json -- +{"project": 101} +-- empty.json -- +[] +-- default.json -- +[ + {"id": 1, "name": "onboarding_banner", "type": "STANDARD", + "environment_feature_state": {"enabled": true, "feature_state_value": null}}, + {"id": 2, "name": "max_items", "type": "STANDARD", + "environment_feature_state": {"enabled": false, "feature_state_value": 25}} +] +-- long-value.json -- +[ + {"id": 1, "name": "blob", "type": "MULTIVARIATE", + "environment_feature_state": {"enabled": false, + "feature_state_value": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}} +] +-- multiline-value.json -- +[ + {"id": 1, "name": "blob", "type": "STANDARD", + "environment_feature_state": {"enabled": true, + "feature_state_value": "[\n {\n \"value\": \"EQUAL\"\n }\n]"}} +] +-- expect-list -- +NAME TYPE STATE VALUE LIFECYCLE +onboarding_banner standard on - live +max_items standard off 25 - + +2 flags +-- expect-json-shape -- +onboarding_banner +true +false +true +-- expect-long-value -- +NAME TYPE STATE VALUE LIFECYCLE +blob multivariate off xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx… - + +1 flag +-- expect-multiline-value -- +NAME TYPE STATE VALUE LIFECYCLE +blob standard on [ { "value": "EQUAL" } ] - + +1 flag diff --git a/internal/cmd/testdata/script/flag-reorder.txtar b/internal/cmd/testdata/script/flag-reorder.txtar new file mode 100644 index 0000000..4aac0fa --- /dev/null +++ b/internal/cmd/testdata/script/flag-reorder.txtar @@ -0,0 +1,104 @@ +# max_items has overrides beta-optin (57, priority 0, "blue", on) and +# us-adults (42, priority 1, 25, off). The environment default is off at 25. + +# Given: a flag whose overrides are listed in a new order +# When: they are reordered +# Then: every override is re-permuted in one request, each echoing its current state, with the environment default carried along +fake feature-overrides +fake forget-requests +exec flagsmith flag reorder max_items us-adults beta-optin --yes +stderr 'Reordered 2 segment overrides for max_items' +cmp stdout expect-reordered +dump requests requests.txt +grep -count=1 'update-flag-v2' requests.txt +grep '"segment_overrides":\[\{"segment_id":42,"enabled":false,"value":\{"type":"integer","value":"25"\},"priority":0\},\{"segment_id":57,"enabled":true,"value":\{"type":"string","value":"blue"\},"priority":1\}\]' requests.txt +grep '"environment_default":\{"enabled":false' requests.txt + +# Given: a flag whose overrides carry their segments' names already +# When: they are reordered +# Then: the names need no segment listing, and the rows behind them are read once each +fake feature-overrides +fake forget-requests +exec flagsmith flag reorder max_items us-adults beta-optin --yes +dump requests requests.txt +! grep 'GET /api/v1/projects/101/segments/' requests.txt +grep -count=1 'GET /api/v1/features/feature-segments/' requests.txt +grep -count=1 'GET /api/v1/features/featurestates/' requests.txt + +# Given: an override whose state row has gone missing, as a concurrent delete would leave it +# When: a reorder is attempted +# Then: it refuses, naming the segment, rather than echoing a zero state over the override +fake feature-overrides +fake feature-states 2 missing-row.json +fake forget-requests +! exec flagsmith flag reorder max_items us-adults beta-optin --yes +stderr '42' +dump requests requests.txt +! grep update-flag-v2 requests.txt + +# Given: a reorder naming a segment the project does not have +# When: it is attempted +# Then: it fails naming that segment, and nothing is written +fake feature-overrides +fake forget-requests +! exec flagsmith flag reorder max_items beta-optin nope --yes +stderr 'segment "nope" not found' +dump requests requests.txt +! grep update-flag-v2 requests.txt + +# Given: a reorder listing only some of the flag's overrides +# When: it is attempted +# Then: it is a usage error naming the one left out, and nothing is written +fake feature-overrides +fake forget-requests +! exec flagsmith flag reorder max_items beta-optin --yes +stderr 'us-adults' +stderr 'Usage:' +dump requests requests.txt +! grep update-flag-v2 requests.txt + +# Given: a reorder naming a segment the flag has no override for +# When: it is attempted +# Then: it is a usage error naming that segment, and nothing is written +fake feature-overrides +fake forget-requests +! exec flagsmith flag reorder max_items beta-optin us-adults beta-cohort --yes +stderr 'beta-cohort' +stderr 'Usage:' +dump requests requests.txt +! grep update-flag-v2 requests.txt + +# Given: a reorder naming one segment twice +# When: it is attempted +# Then: it is a usage error naming the duplicate, and nothing is written +fake feature-overrides +fake forget-requests +! exec flagsmith flag reorder max_items beta-optin beta-optin --yes +stderr 'beta-optin' +stderr 'Usage:' +dump requests requests.txt +! grep update-flag-v2 requests.txt + +# Given: a reorder and no interactive terminal to confirm on +# When: it is attempted without --yes +# Then: it is a usage error, and nothing is written +fake feature-overrides +fake forget-requests +! exec flagsmith flag reorder max_items us-adults beta-optin +stderr 'Usage:' +dump requests requests.txt +! grep update-flag-v2 requests.txt + +-- flagsmith.json -- +{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN"} +-- missing-row.json -- +[ + {"id": 9000, "feature_segment": null, "enabled": false, "feature_state_value": {"type": "unicode", "string_value": "default"}}, + {"id": 9001, "feature_segment": 1200, "enabled": true, "feature_state_value": {"type": "unicode", "string_value": "blue"}} +] +-- expect-reordered -- +PRIORITY SEGMENT STATE VALUE +0 us-adults (42) off 25 +1 beta-optin (57) on blue + +2 overrides diff --git a/internal/cmd/testdata/script/flag-segment-by-name.txtar b/internal/cmd/testdata/script/flag-segment-by-name.txtar new file mode 100644 index 0000000..c5f5194 --- /dev/null +++ b/internal/cmd/testdata/script/flag-segment-by-name.txtar @@ -0,0 +1,62 @@ +# A segment can be named wherever it can be numbered. The project has segments +# 42 "us-adults" and 57 "beta-optin". + +# Given: a segment named us-adults with id 42 +# When: the flags are listed for it by name +# Then: the name is resolved to its id before the features are asked for +fake segment-override on +exec flagsmith flag list --segment us-adults +dump requests requests.txt +grep 'GET /api/v1/projects/101/features/.*segment=42' requests.txt + +# Given: a segment named us-adults with id 42 +# When: a flag's override is fetched for it by name +# Then: the name is resolved to its id before the features are asked for +fake segment-override on +fake forget-requests +exec flagsmith flag get max_items --segment us-adults +dump requests requests.txt +grep 'GET /api/v1/projects/101/features/.*segment=42' requests.txt + +# Given: a segment named beta-optin with id 57 +# When: a flag's override is enabled for it by name +# Then: the resolved id is what the write carries +fake segment-override on +exec flagsmith flag update max_items --segment beta-optin --enable --yes +dump requests requests.txt +grep '"segment_overrides":\[\{"segment_id":57' requests.txt + +# Given: a segment named us-adults with id 42 +# When: a flag's override is disabled for it by name +# Then: the resolved id is what the write carries, turned off +fake segment-override on +exec flagsmith flag disable max_items --segment us-adults --yes +dump requests requests.txt +grep '"segment_overrides":\[\{"segment_id":42,"enabled":false' requests.txt + +# Given: a segment named us-adults with id 42 +# When: a flag's override is deleted for it by name +# Then: the resolved id is what the delete carries, and the message names the segment +fake segment-override on +exec flagsmith flag delete max_items --segment us-adults --yes +stderr 'Deleted max_items override for segment us-adults \(42\) in environment' +dump requests requests.txt +grep '"segment":\{"id":42' requests.txt + +# Given: a segment name that has to be looked up, and a cold name cache +# When: the flags are listed for it by name +# Then: every segment the lookup saw is remembered, not just the one asked for +fake segment-override on +exec flagsmith flag list --segment us-adults +grep '"42":"us-adults"' $CACHE +grep '"57":"beta-optin"' $CACHE + +# Given: a segment name the project does not have +# When: the flags are listed for it +# Then: it fails, offering the listing that would show the real names +! exec flagsmith flag list --segment ghost +stderr 'ghost' +stderr 'Run `flagsmith segment list`' + +-- flagsmith.json -- +{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN"} diff --git a/internal/cmd/testdata/script/flag-update-priority.txtar b/internal/cmd/testdata/script/flag-update-priority.txtar new file mode 100644 index 0000000..3c3d2a2 --- /dev/null +++ b/internal/cmd/testdata/script/flag-update-priority.txtar @@ -0,0 +1,46 @@ +# Given: a flag overridden on at "special" for segment 12, sitting at priority 1 +# When: it is moved to priority 0 +# Then: the move is confirmed by segment name, and the override's current state rides along unchanged +fake segment-override on +fake feature-segments 2 override-meta.json +exec flagsmith flag update max_items --segment 12 --priority 0 --yes +stderr 'Set max_items priority to 0 for segment powerusers \(12\) in environment' +stdout 'Priority' +dump requests requests.txt +grep '"segment_overrides":\[\{"segment_id":12,"enabled":true,"value":\{"type":"string","value":"special"\},"priority":0\}\]' requests.txt + +# Given: a flag overridden on for segment 12, sitting at priority 1 +# When: it is disabled and moved in one invocation +# Then: both changes travel in one request +fake segment-override on +fake feature-segments 2 override-meta.json +exec flagsmith flag update max_items --segment 12 --disable --priority 0 --yes +dump requests requests.txt +grep '"segment_id":12,"enabled":false,.*"priority":0' requests.txt + +# Given: a flag and a priority, but no segment to apply it to +# When: --priority is passed on its own +# Then: it is a usage error naming --segment, and nothing is written +fake forget-requests +! exec flagsmith flag update max_items --priority 0 --yes +stderr '--segment' +stderr 'Usage:' +dump requests requests.txt +! grep update-flag-v2 requests.txt + +# Given: a flag with one override, so the only valid priority is 0 +# When: a priority beyond that is asked for +# Then: it is a usage error naming --priority, refused before any write +fake segment-override on +fake feature-segments 2 override-meta.json +fake forget-requests +! exec flagsmith flag update max_items --segment 12 --priority 5 --yes +stderr '--priority' +stderr 'Usage:' +dump requests requests.txt +! grep update-flag-v2 requests.txt + +-- flagsmith.json -- +{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN"} +-- override-meta.json -- +[{"id": 1200, "segment": 12, "segment_name": "powerusers", "priority": 1, "environment": 1}] diff --git a/internal/cmd/testdata/script/flag-update-rejections.txtar b/internal/cmd/testdata/script/flag-update-rejections.txtar new file mode 100644 index 0000000..640d8e6 --- /dev/null +++ b/internal/cmd/testdata/script/flag-update-rejections.txtar @@ -0,0 +1,36 @@ +# Given: max_items, an existing flag +# When: --enable and --disable are passed together +# Then: it is a usage error, and nothing is written +! exec flagsmith flag update max_items --enable --disable --yes +stderr 'mutually exclusive' +stderr 'Usage:' +dump requests requests.txt +! grep update-flag-v2 requests.txt + +# Given: max_items, an existing flag +# When: no change is asked for +# Then: it is a usage error naming what could have been passed, and nothing is written +! exec flagsmith flag update max_items --yes +stderr 'nothing to update' +dump requests requests.txt +! grep update-flag-v2 requests.txt + +# Given: a name no feature has +# When: an update is attempted +# Then: it fails after the lookup, pointing at the listing, and nothing is written +! exec flagsmith flag update ghost --enable --yes +stderr 'feature "ghost" not found' +stderr 'Run `flagsmith flag list`' +dump requests requests.txt +! grep update-flag-v2 requests.txt + +# Given: max_items, an existing flag, and no interactive terminal to confirm on +# When: an update is attempted without --yes +# Then: it is a usage error saying how to confirm, and nothing is written +! exec flagsmith flag update max_items --enable +stderr 'pass --yes to confirm' +dump requests requests.txt +! grep update-flag-v2 requests.txt + +-- flagsmith.json -- +{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN"} diff --git a/internal/cmd/testdata/script/flag-update-segment.txtar b/internal/cmd/testdata/script/flag-update-segment.txtar new file mode 100644 index 0000000..831da51 --- /dev/null +++ b/internal/cmd/testdata/script/flag-update-segment.txtar @@ -0,0 +1,66 @@ +# Given: a flag turned off at 25, overridden on for segment 12 +# When: that override is given a new value +# Then: the override changes and the environment default travels alongside it, unchanged +fake segment-override on +exec flagsmith flag update max_items --segment 12 --value new --yes +stderr 'Set max_items to "new" for segment 12 in environment' +dump requests requests.txt +grep '"environment_default":\{"enabled":false,"value":\{"type":"integer","value":"25"\}\}' requests.txt +grep '"segment_overrides":\[\{"segment_id":12,"enabled":true,"value":\{"type":"string","value":"new"\}' requests.txt + +# Given: a flag turned on, with no override for segment 7 +# When: an override is created for that segment +# Then: it inherits the environment default's state +fake features-default +exec flagsmith flag update onboarding_banner --segment 7 --value yo --yes +dump requests requests.txt +grep '"segment_overrides":\[\{"segment_id":7,"enabled":true' requests.txt + +# Given: a flag at 25, with no override for segment 7 +# When: an override is created for that segment with only --enable +# Then: it inherits the environment default's value as well as being turned on +fake segment-override off +exec flagsmith flag update max_items --segment 7 --enable --yes +dump requests requests.txt +grep '"segment_overrides":\[\{"segment_id":7,"enabled":true,"value":\{"type":"integer","value":"25"\}' requests.txt + +# Given: a flag overridden for segment 12, whose priority and name the API knows +# When: that override is given a new value +# Then: the reprinted detail carries the priority and segment name, read once each +fake segment-override on +fake feature-segments 2 override-meta.json +fake forget-requests +exec flagsmith flag update max_items --segment 12 --value new --yes +stdout 'Priority' +stdout 'powerusers \(12\)' +stdout 'new' +dump requests requests.txt +grep -count=1 'GET /api/v1/projects/101/features/' requests.txt +grep -count=1 'GET /api/v1/features/feature-segments/' requests.txt + +# Given: a flag with overrides for two segments, at priorities 0 and 1 +# When: the second is moved to priority 0 +# Then: the reprinted detail shows it at its new priority +fake segment-override on +fake feature-segments 2 two-priorities.json +exec flagsmith flag update max_items --segment 12 --priority 0 --yes +stdout 'Priority' +stdout '0' + +# Given: a flag with one override, and a segment that has none +# When: an override is created for that segment +# Then: the reprinted detail reports the priority it was appended at +fake segment-override off +exec flagsmith flag update max_items --segment 7 --enable --yes +stdout 'Priority' +stdout '1' + +-- flagsmith.json -- +{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN"} +-- override-meta.json -- +[{"id": 1200, "segment": 12, "segment_name": "powerusers", "priority": 1, "environment": 1}] +-- two-priorities.json -- +[ + {"id": 1199, "segment": 9, "segment_name": "beta", "priority": 0, "environment": 1}, + {"id": 1200, "segment": 12, "segment_name": "powerusers", "priority": 1, "environment": 1} +] diff --git a/internal/cmd/testdata/script/flag-update.txtar b/internal/cmd/testdata/script/flag-update.txtar new file mode 100644 index 0000000..eef2625 --- /dev/null +++ b/internal/cmd/testdata/script/flag-update.txtar @@ -0,0 +1,40 @@ +# Given: max_items, an integer flag set to 25 and turned off +# When: it is enabled +# Then: the value is preserved and the flag reprinted, with no refetch after the write +exec flagsmith flag update max_items --enable --yes +cmp stdout expect-detail +cmp stderr expect-confirmation +dump requests requests.txt +cmpenv requests.txt expect-requests + +# Given: onboarding_banner, a flag with no value +# When: it is set to the literal 42 +# Then: the type is inferred as integer, and the message says so unquoted +exec flagsmith flag update onboarding_banner --value 42 --yes +stdout 'Value +42' +stderr 'Set onboarding_banner to 42' + +# Given: max_items, and a value that looks like a number +# When: --type string forces the type +# Then: the literal stays a string, and the message quotes it to show that +exec flagsmith flag update max_items --value 25 --type string --yes +stderr 'Set max_items to "25"' + +-- flagsmith.json -- +{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN"} +-- expect-detail -- +Feature max_items +Description +Type standard +State on +Value 25 +Segment overrides 1 +Identity overrides 2 +Code references 3 +Lifecycle stage - +-- expect-confirmation -- +✓ Enabled max_items in environment Development (WqXhZk8sVY3dGgTqZ9pJmN) +-- expect-requests -- +GET /api/v1/environments/?project=101&page_size=999 [master-key] +GET /api/v1/projects/101/features/?environment=1&search=max_items&page_size=999 [master-key] +POST /api/experiments/environments/WqXhZk8sVY3dGgTqZ9pJmN/update-flag-v2/ [master-key] {"feature":{"name":"max_items"},"environment_default":{"enabled":true,"value":{"type":"integer","value":"25"}}} diff --git a/internal/cmd/testdata/script/init-interactive.txtar b/internal/cmd/testdata/script/init-interactive.txtar new file mode 100644 index 0000000..39d09f3 --- /dev/null +++ b/internal/cmd/testdata/script/init-interactive.txtar @@ -0,0 +1,161 @@ +# `init` with a terminal. +# Interactive prompts tested via bubbletea's accessible mode. + +[!unix] skip 'ttyin needs a pty, which testscript provides on unix only' + +# Given: a user in two organisations, the second holding a project with an environment +# When: init is answered by picking the second of each +# Then: the picked organisation, project and environment are what get written +mkdir multiorg +cd multiorg +fake orgs Acme=3 Beta=7 +fake projects 7 $WORK/beta-projects.json +fake project-environments 202 $WORK/beta-envs.json +ttyin -stdin $WORK/pick-2-1-1 +exec flagsmith init +stderr 'Organisation' +stderr 'Project' +stderr 'environment' +stderr 'Wrote flagsmith.json' +exec flagsmith config --json --jq '.organisation.value, .project.value, .environment.value' +cmp stdout $WORK/expect-beta +cd $WORK + +# Given: a user in two organisations, the second holding a project with an environment +# When: init is answered from a terminal +# Then: every prompt goes to stderr, leaving stdout empty for data +mkdir streams +cd streams +fake orgs Acme=3 Beta=7 +fake projects 7 $WORK/beta-projects.json +fake project-environments 202 $WORK/beta-envs.json +ttyin -stdin $WORK/pick-2-1-1 +exec flagsmith init +! stdout . +stderr 'Organisation' +stderr 'Project' +stderr 'Default environment' +cd $WORK + +# Given: a user in one organisation with no project chosen yet +# When: the project picker is answered with "create", and the defaults accepted +# Then: a project named after the directory is created, with a Development environment +mkdir createproject +cd createproject +fake orgs Acme=3 +fake forget-requests +ttyin -stdin $WORK/pick-2-then-defaults +exec flagsmith init +stderr 'Created project' +dump requests requests.txt +grep 'POST /api/v1/projects/.*"name":"createproject"' requests.txt +grep 'POST /api/v1/environments/.*"name":"Development"' requests.txt +exec flagsmith config --json --jq '.project.value, .environment.value' +cmp stdout $WORK/expect-created +cd $WORK + +# Given: a project with no environments in it, and a terminal to ask on +# When: init is answered by accepting the offer to create one +# Then: a Development environment is created and written +mkdir emptyproject +cd emptyproject +fake environments-named +fake forget-requests +ttyin -stdin $WORK/pick-1-then-default +exec flagsmith init +stderr 'environment' +dump requests requests.txt +grep 'POST /api/v1/environments/.*"name":"Development"' requests.txt +exec flagsmith config --json --jq .environment.value +stdout '^createdEnvKey00000000$' +fake environments +cd $WORK + +# Given: a config that already records an organisation, and a user in two of them +# When: init is re-run and the pre-selected organisation accepted +# Then: the picker is re-offered, and the current organisation is what it defaults to +mkdir reinit +cd reinit +fake orgs Beta=7 Acme=3 +fake projects 7 $WORK/beta-projects.json +fake project-environments 202 $WORK/beta-envs.json +cp $WORK/with-organisation.json flagsmith.json +ttyin -stdin $WORK/pick-default-1-1 +exec flagsmith init +stderr 'Organisation' +exec flagsmith config --json --jq .organisation.value +stdout '^3$' +cd $WORK + +# Given: a picker answered with a number outside its range +# When: init runs +# Then: it says what the range is and re-asks rather than failing +mkdir reprompt +cd reprompt +fake orgs Acme=3 +fake environments +ttyin -stdin $WORK/pick-99-1-1 +exec flagsmith init +stderr 'between 1 and' +exec flagsmith config --json --jq .project.value +stdout '^101$' +cd $WORK + +# Given: a config recording a project that init is about to change +# When: init is re-run and the change confirmed +# Then: the difference is shown as a diff, and confirmed before anything is written +mkdir diff +cd diff +cp $WORK/with-project-12345.json flagsmith.json +ttyin -stdin $WORK/pick-1-2-yes +exec flagsmith init +stdout '- "project": 12345' +stdout '\+ "project": 101' +stderr 'Write changes\?' +exec flagsmith config --json --jq '.project.value, .environment.value' +cmp stdout $WORK/expect-changed +cd $WORK + +-- flagsmith.json -- +{"project": 101} +-- beta-projects.json -- +[{"id": 202, "name": "beta-app", "organisation": 7}] +-- beta-envs.json -- +[{"id": 9, "name": "Development", "api_key": "BetaDevKey00000000000"}] +-- with-organisation.json -- +{"project": 101, "organisation": 3, "environment": "WqXhZk8sVY3dGgTqZ9pJmN"} +-- with-project-12345.json -- +{"project": 12345, "environment": "WqXhZk8sVY3dGgTqZ9pJmN"} +-- pick-2-1-1 -- +2 +1 +1 +-- pick-2-then-defaults -- +2 + + +-- pick-1-then-default -- +1 + +-- pick-default-1-1 -- + +1 +1 +-- pick-99-1-1 -- +99 +1 +1 +-- pick-1-2-yes -- +1 +2 +y +-- expect-beta -- +7 +202 +BetaDevKey00000000000 +-- expect-created -- +999 +createdEnvKey00000000 +-- expect-changed -- +101 +K2mVsGdXhZ8kQqZ9pJmNbJ diff --git a/internal/cmd/testdata/script/init.txtar b/internal/cmd/testdata/script/init.txtar new file mode 100644 index 0000000..8333ac4 --- /dev/null +++ b/internal/cmd/testdata/script/init.txtar @@ -0,0 +1,189 @@ +# `init` without a terminal: everything it can be told on the command line. +# Each case runs in a directory of its own, since init writes flagsmith.json +# into the one it is run from. + +# Given: an empty repository and a credential that reaches the instance +# When: init is told both the project and the environment +# Then: it verifies access, writes the config with the instance recorded, and seeds the name cache +mkdir plain +cd plain +exec flagsmith init --project 12345 --environment WqXhZk8sVY3dGgTqZ9pJmN --yes +stderr 'Verified access' +stderr 'Wrote flagsmith.json' +exec flagsmith config --json --jq '.project.value, .environment.value' +cmp stdout $WORK/expect-plain +grep 'schema/flagsmith.json' flagsmith.json +grep '"WqXhZk8sVY3dGgTqZ9pJmN":"Development"' $CACHE +cd $WORK + +# Given: an empty repository and no project to write +# When: init runs non-interactively +# Then: it is a usage error naming the flag that would have supplied one +mkdir noproject +cd noproject +! exec flagsmith init --yes +stderr '--project' +stderr 'Usage:' +cd $WORK + +# Given: an empty repository and an empty --project +# When: init runs non-interactively +# Then: it is a usage error rather than an empty reference being written +mkdir emptyproject +cd emptyproject +! exec flagsmith init --project '' --yes +stderr 'Usage:' +cd $WORK + +# Given: an instance with exactly one organisation, and an empty --organisation +# When: a project is created +# Then: the lone organisation is used rather than the empty reference +mkdir emptyorg +cd emptyorg +exec flagsmith init --organisation '' --create-project smoke --yes +stderr 'Wrote flagsmith.json' +cd $WORK + +# Given: an empty repository and no credentials at all +# When: init runs +# Then: it fails, offering both the variable and the command that would supply one +mkdir nocreds +cd nocreds +env $API_KEY_VAR= +! exec flagsmith init --project 12345 --yes +stderr 'FLAGSMITH_API_KEY' +stderr 'flagsmith login' +env $API_KEY_VAR=$MASTER_KEY +cd $WORK + +# Given: a repository that already has a flagsmith.json +# When: init runs without --yes +# Then: it is a usage error naming --yes rather than overwriting +mkdir existing +cd existing +cp $WORK/existing-config.json flagsmith.json +! exec flagsmith init --project 12345 +stderr '--yes' +stderr 'Usage:' +cd $WORK + +# Given: an empty repository, and a project and environment to be created +# When: init is told to create both +# Then: both are created and the new references are what get written +mkdir createboth +cd createboth +fake forget-requests +exec flagsmith init --create-project acme-new --create-environment Development --yes +dump requests requests.txt +grep 'POST /api/v1/projects/.*"name":"acme-new"' requests.txt +grep 'POST /api/v1/environments/.*"name":"Development"' requests.txt +exec flagsmith config --json --jq '.project.value, .environment.value' +cmp stdout $WORK/expect-created +cd $WORK + +# Given: an instance with more than one organisation +# When: a project is created without saying which organisation it belongs to +# Then: it is a usage error naming the flag that would say +mkdir multiorg +cd multiorg +fake orgs Acme=3 Beta=7 +! exec flagsmith init --create-project x --yes +stderr 'organisation' +stderr 'Usage:' +fake orgs Acme=3 +cd $WORK + +# Given: a project both named and asked to be created +# When: init runs +# Then: it is a usage error — the two flags describe different intents +mkdir conflictproject +cd conflictproject +! exec flagsmith init --project 101 --create-project x --yes +stderr 'mutually exclusive' +cd $WORK + +# Given: an existing project, and an environment to be created in it +# When: init is told to create it +# Then: the environment is created and its minted key is what gets written +mkdir createenv +cd createenv +fake forget-requests +exec flagsmith init --project 101 --create-environment Staging --yes +dump requests requests.txt +grep 'POST /api/v1/environments/.*"name":"Staging"' requests.txt +exec flagsmith config --json --jq .environment.value +stdout '^createdEnvKey00000000$' +cd $WORK + +# Given: an environment both named and asked to be created +# When: init runs +# Then: it is a usage error — the two flags describe different intents +mkdir conflictenv +cd conflictenv +! exec flagsmith init --project 101 --environment WqXhZk8sVY3dGgTqZ9pJmN --create-environment x --yes +stderr 'mutually exclusive' +cd $WORK + +# Given: a project with no environments in it, and no terminal to offer to create one +# When: init runs +# Then: nothing is created and no environment is written +mkdir noenvs +cd noenvs +fake environments-named +fake forget-requests +exec flagsmith init --project 101 --yes +dump requests requests.txt +! grep 'POST /api/v1/environments/' requests.txt +exec flagsmith config --json --jq .environment.value +stdout '^null$' +fake environments +cd $WORK + +# Given: a config that already records an organisation +# When: init rewrites it +# Then: the organisation is preserved rather than dropped +mkdir preserveorg +cd preserveorg +cp $WORK/with-organisation.json flagsmith.json +exec flagsmith init --project 12345 --yes +exec flagsmith config --json --jq .organisation.value +stdout '^3$' +cd $WORK + +# Given: a config that already records a non-default SDK API URL +# When: init rewrites it +# Then: that URL is preserved +mkdir preservesdk +cd preservesdk +cp $WORK/with-sdk-url.json flagsmith.json +exec flagsmith init --project 12345 --yes +exec flagsmith config --json --jq .sdkApiUrl.value +stdout 'sdk.acme.internal' +cd $WORK + +# Given: a repository whose flagsmith.json is not valid JSON +# When: init is pointed at a different config to write +# Then: it refuses, and the malformed file is left exactly as it was +mkdir malformed +cd malformed +cp $WORK/broken.json flagsmith.json +! exec flagsmith init --config-path elsewhere.json --project 12345 --yes +cmp flagsmith.json $WORK/broken.json +cd $WORK + +-- flagsmith.json -- +{"project": 101} +-- existing-config.json -- +{"project": 1} +-- with-organisation.json -- +{"project": 12345, "organisation": 3, "environment": "WqXhZk8sVY3dGgTqZ9pJmN"} +-- with-sdk-url.json -- +{"project": 12345, "environment": "WqXhZk8sVY3dGgTqZ9pJmN", "sdkApiUrl": "https://sdk.acme.internal"} +-- broken.json -- +{ this is not valid json +-- expect-plain -- +12345 +WqXhZk8sVY3dGgTqZ9pJmN +-- expect-created -- +999 +createdEnvKey00000000 diff --git a/internal/cmd/testdata/script/organisation.txtar b/internal/cmd/testdata/script/organisation.txtar new file mode 100644 index 0000000..c9c0829 --- /dev/null +++ b/internal/cmd/testdata/script/organisation.txtar @@ -0,0 +1,74 @@ +# Given: an instance with two organisations +# When: they are listed +# Then: both are shown with their ids, and counted +fake orgs Acme=3 Beta=7 +exec flagsmith organisation list +cmp stdout expect-list + +# Given: an instance with one organisation +# When: the command is spelled `org` +# Then: it is an alias for organisation +fake orgs Acme=3 +exec flagsmith org list +stdout 'Acme' + +# Given: an instance the credential can see no organisations in +# When: they are listed as JSON +# Then: the empty list renders as [] rather than null, so --jq can iterate it +fake orgs +exec flagsmith organisation list --json +stdout '^\[\]$' +exec flagsmith organisation list --jq .[] +! stdout . + +# Given: an organisation named Acme with id 3 +# When: it is fetched by name +# Then: it is shown by name and id +fake orgs Acme=3 +exec flagsmith organisation get Acme +stdout 'Acme \(3\)' + +# Given: an organisation carrying API fields the CLI has no opinion about +# When: it is fetched as JSON +# Then: those fields are passed through untouched +fake orgs Acme=3 +fake org-fields force_2fa=true webhook_notification_email=x@y.com +exec flagsmith organisation get 3 --json +stdout '"force_2fa": true' +stdout '"webhook_notification_email": "x@y.com"' + +# Given: an instance where an organisation is to be created +# When: create is called with --force-2fa +# Then: the flag reaches the request body, and the creation is confirmed +fake orgs Acme=3 +exec flagsmith organisation create 'Acme Labs' --force-2fa +stderr 'Created organisation Acme Labs' +dump requests requests.txt +grep '"force_2fa":true' requests.txt +grep '"name":"Acme Labs"' requests.txt + +# Given: an organisation named Acme +# When: its webhook email is updated +# Then: the new address reaches the request body +fake orgs Acme=3 +exec flagsmith organisation update Acme --webhook-email a@b.com +dump requests requests.txt +grep '"webhook_notification_email":"a@b.com"' requests.txt + +# Given: an organisation named Acme with id 3 +# When: it is deleted +# Then: the deletion is confirmed and the organisation is gone +fake orgs Acme=3 +exec flagsmith organisation delete Acme --yes +stderr 'Deleted organisation Acme \(3\)' +exec flagsmith organisation list +! stdout 'Acme' + +-- flagsmith.json -- +{"project": 101} +-- expect-list -- +NAME ID +Acme 3 +Beta 7 + +2 organisations diff --git a/internal/cmd/testdata/script/project.txtar b/internal/cmd/testdata/script/project.txtar new file mode 100644 index 0000000..f05b714 --- /dev/null +++ b/internal/cmd/testdata/script/project.txtar @@ -0,0 +1,162 @@ +# Given: an organisation holding two projects +# When: they are listed +# Then: each is shown with its id and its organisation's name, and counted +fake orgs Acme=3 +fake projects 3 acme.json +exec flagsmith project list +cmp stdout expect-list + +# Given: a directory pinned to one organisation, and a project in another +# When: the projects are listed +# Then: only the pinned organisation's projects are shown, as `project get` would resolve +fake orgs Acme=3 Other=4 +fake projects 3 acme.json +fake projects 4 other.json +exec flagsmith project list -c pinned.json +stdout 'acme-api' +! stdout 'other-api' + +# Given: a project named acme-api with id 101 +# When: it is fetched by name +# Then: it is shown by name and id +fake orgs Acme=3 +fake projects 3 acme.json +exec flagsmith project get acme-api +stdout 'acme-api \(101\)' + +# Given: an organisation named rather than numbered +# When: its projects are listed +# Then: the one organisation fetch that resolved the name also labels the rows +fake orgs Acme=3 +fake projects 3 acme.json +fake forget-requests +exec flagsmith project list --organisation Acme +stdout 'Acme \(3\)' +dump requests requests.txt +grep -count=1 'GET /api/v1/organisations/' requests.txt + +# Given: two organisations, one holding a project named site +# When: that name is looked up scoped to the organisation without it +# Then: it is not found, and the error says which organisation was searched +fake orgs Acme=3 Beta=7 +fake projects 7 beta.json +! exec flagsmith project get site --organisation Acme +stderr '"site" not found in organisation 3' + +# Given: two organisations, one holding a project named site +# When: that name is looked up scoped to the organisation with it +# Then: it resolves +fake orgs Acme=3 Beta=7 +fake projects 7 beta.json +exec flagsmith project get site --organisation Beta +stdout 'site \(701\)' + +# Given: two organisations, one holding a project named site +# When: that name is looked up with no organisation scope at all +# Then: it still resolves, across organisations +fake orgs Acme=3 Beta=7 +fake projects 7 beta.json +exec flagsmith project get site +stdout 'site \(701\)' + +# Given: a project named by its id +# When: it is deleted +# Then: the message carries the bare id, not an id labelled with itself +fake orgs Acme=3 +fake projects 3 acme.json +cache projects +exec flagsmith project delete 101 --yes +stderr 'Deleted project 101' +! stderr '101 \(101\)' + +# Given: a project named acme-api with id 101 +# When: it is deleted by name +# Then: the message carries both the name and the id, and the project is gone +fake orgs Acme=3 +fake projects 3 acme.json +exec flagsmith project delete acme-api --yes +stderr 'Deleted project acme-api \(101\)' +exec flagsmith project list +! stdout 'acme-api' + +# Given: a name cache warmed by a project listing +# When: a project is fetched +# Then: the organisation is labelled from the cache, with no organisation fetch at all +fake orgs Acme=3 +fake projects 3 acme.json +exec flagsmith project list --organisation Acme +fake forget-requests +exec flagsmith project get acme-api +stdout 'Acme \(3\)' +dump requests requests.txt +! grep 'GET /api/v1/organisations/' requests.txt + +# Given: a project carrying API fields the CLI has no opinion about +# When: it is fetched as JSON +# Then: those fields are passed through untouched +fake orgs Acme=3 +fake projects 3 rich.json +exec flagsmith project get 101 --json --jq .hide_disabled_flags +stdout '^true$' + +# Given: an organisation to create a project in +# When: one is created +# Then: the organisation is resolved onto the request body, and the creation is confirmed +fake orgs Acme=3 +fake projects 3 acme.json +exec flagsmith project create acme-mobile --organisation Acme +stderr 'Created project acme-mobile' +dump requests requests.txt +grep '"name":"acme-mobile","organisation":3' requests.txt + +# Given: an existing project +# When: one of its settings is changed +# Then: the setting reaches the request body +fake orgs Acme=3 +fake projects 3 acme.json +exec flagsmith project update acme-api --hide-disabled-flags +dump requests requests.txt +grep '"hide_disabled_flags":true' requests.txt + +# Given: a configuration naming its project by name and its environment by name +# When: the flags are listed +# Then: the project name resolves far enough to look the environment up by name too +fake orgs Acme=3 +fake projects 3 acme.json +fake environments +fake forget-requests +exec flagsmith flag list -c by-name.json +dump requests requests.txt +grep 'features/\?environment=1' requests.txt + +# Given: a configuration naming a project the instance does not have +# When: the flags are listed +# Then: it fails, naming the project that could not be resolved +! exec flagsmith flag list -c ghost.json +stderr 'ghost' + +-- flagsmith.json -- +{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN"} +-- ghost.json -- +{"project": "ghost", "environment": "Development"} +-- pinned.json -- +{"organisation": 3, "project": 101} +-- by-name.json -- +{"project": "acme-api", "environment": "Development"} +-- acme.json -- +[ + {"id": 101, "name": "acme-api", "organisation": 3}, + {"id": 102, "name": "acme-web", "organisation": 3} +] +-- other.json -- +[{"id": 201, "name": "other-api", "organisation": 4}] +-- beta.json -- +[{"id": 701, "name": "site", "organisation": 7}] +-- rich.json -- +[{"id": 101, "name": "acme-api", "organisation": 3, "hide_disabled_flags": true}] +-- expect-list -- +NAME ID ORGANISATION +acme-api 101 Acme (3) +acme-web 102 Acme (3) + +2 projects diff --git a/internal/cmd/testdata/script/segment.txtar b/internal/cmd/testdata/script/segment.txtar new file mode 100644 index 0000000..5066257 --- /dev/null +++ b/internal/cmd/testdata/script/segment.txtar @@ -0,0 +1,113 @@ +# Given: a project with two ordinary segments and one tied to a feature +# When: the segments are listed +# Then: only the ordinary ones are shown — a feature-specific segment is not a project segment +exec flagsmith segment list +cmp stdout expect-list +! stdout 'beta-cohort' + +# Given: a project with two ordinary segments and one tied to a feature +# When: they are listed with --include-feature-specific +# Then: the feature-specific one is shown too +exec flagsmith segment list --include-feature-specific +stdout 'beta-cohort' +stdout '3 segments' + +# Given: a project with two ordinary segments and one tied to a feature +# When: they are listed as JSON +# Then: it is an array of the curated segments, without the feature-specific one +exec flagsmith segment list --json --jq 'length' +stdout '^2$' + +# Given: a segment named us-adults with a two-level rule tree +# When: it is fetched by name +# Then: the tree is rendered, ending in a nudge towards the flags it overrides +exec flagsmith segment get us-adults +cmp stdout expect-get +stderr 'flag list --segment 42' + +# Given: a segment whose rule tree has an IN condition +# When: it is fetched by id as JSON +# Then: the rules carry a $schema, and IN decodes to an array rather than a packed string +exec flagsmith segment get 42 --json --jq '.rules["$schema"]' +stdout 'SegmentRule' +exec flagsmith segment get 42 --json --jq '.rules.rules[0].conditions[0].value | type' +stdout '^array$' +exec flagsmith segment get 42 --json --jq '.rules.rules[0].conditions[0].value | join(",")' +stdout '^US,CA$' + +# Given: a rule tree with an IN condition, written as an array +# When: a segment is created from it +# Then: the creation is confirmed, and IN goes to the wire as the packed string the API wants +exec flagsmith segment create newseg --rules '{"type":"ALL","rules":[{"type":"ANY","conditions":[{"property":"country","operator":"IN","value":["US","CA"]}]}]}' +stderr 'Created segment newseg' +dump requests requests.txt +grep '"value":"\[\\"US\\",\\"CA\\"\]"' requests.txt + +# Given: a segment named only, with no rules to give it +# When: creation is attempted +# Then: it is a usage error naming the flag that is missing +! exec flagsmith segment create x +stderr '--rules' +stderr 'Usage:' + +# Given: a feature named rather than numbered +# When: a feature-specific segment is created against it +# Then: the name is resolved to its id on the wire +exec flagsmith segment create fs --rules '{"type":"ALL","conditions":[{"property":"beta","operator":"IS_SET"}]}' --feature max_items +dump requests requests.txt +grep '"feature":2' requests.txt + +# Given: a rule tree nested one level deeper than the API allows +# When: a segment is created from it +# Then: it is a usage error saying how deep a tree may be +! exec flagsmith segment create deep --rules '{"type":"ALL","rules":[{"type":"ANY","rules":[{"type":"ALL","conditions":[]}]}]}' +stderr 'two levels' +stderr 'Usage:' + +# Given: a segment named us-adults +# When: an update asks for no change at all +# Then: it is a usage error, and the segment is never PUT +! exec flagsmith segment update us-adults +stderr 'nothing to update' +dump requests requests.txt +! grep 'PUT /api/v1/projects/101/segments/' requests.txt + +# Given: a segment named us-adults with a rule tree +# When: only its description is changed +# Then: the description is updated and the rules travel with it, unchanged +exec flagsmith segment update us-adults --description 'new desc' +dump requests requests.txt +grep '"description":"new desc"' requests.txt +grep '"rules":\[' requests.txt + +# Given: a segment with an existing rule tree +# When: --rules gives it a new one +# Then: the replacement is what goes to the wire +exec flagsmith segment update 42 --rules '{"type":"ALL","conditions":[{"property":"x","operator":"EQUAL","value":"1"}]}' +dump requests requests.txt +grep '"property":"x"' requests.txt + +# Given: a segment named us-adults with id 42 +# When: it is deleted +# Then: the deletion is confirmed and the segment is gone +exec flagsmith segment delete us-adults --yes +stderr 'Deleted segment us-adults \(42\)' +exec flagsmith segment list +! stdout 'us-adults' + +-- flagsmith.json -- +{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN"} +-- expect-list -- +NAME ID CONDITIONS DESCRIPTION +us-adults 42 2 Users in the US aged 18+ +beta-optin 57 1 Opted into the beta + +2 segments +-- expect-get -- +Segment us-adults (42) +Description Users in the US aged 18+ + +All of the below: + Any of the below: + country IN US, CA + age GREATER_THAN_INCLUSIVE 18 diff --git a/internal/cmd/testdata/script/server-side-key.txtar b/internal/cmd/testdata/script/server-side-key.txtar new file mode 100644 index 0000000..f15112f --- /dev/null +++ b/internal/cmd/testdata/script/server-side-key.txtar @@ -0,0 +1,31 @@ +# A server-side key is a secret. Nothing the CLI prints may contain one, on +# either stream, however the key got there. +env $SDK_KEY_VAR=ser.SuperSecret123 + +# Given: a config naming no environment, and a server-side key offered as one +# When: the flags are listed +# Then: the error names the variable it came from, never its value +! exec flagsmith flag list -c no-environment.json +stderr 'FLAGSMITH_ENVIRONMENT_KEY' +! stderr 'SuperSecret123' +! stdout 'SuperSecret123' + +# Given: a server-side key the SDK API does not recognise +# When: an evaluation is attempted with it +# Then: the rejection is reported without echoing the key +! exec flagsmith evaluate -c no-environment.json +! stderr 'SuperSecret123' +! stdout 'SuperSecret123' + +# Given: a server-side key written on the command line where an environment goes +# When: it is used to name an environment +# Then: it is refused, pointing at the variable meant for secrets, without echoing it +! exec flagsmith environment get ser.SuperSecret123 +stderr 'FLAGSMITH_ENVIRONMENT_KEY' +! stderr 'SuperSecret123' +! stdout 'SuperSecret123' + +-- flagsmith.json -- +{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN"} +-- no-environment.json -- +{"project": 101} diff --git a/internal/cmd/testdata/script/usage.txtar b/internal/cmd/testdata/script/usage.txtar new file mode 100644 index 0000000..0f84473 --- /dev/null +++ b/internal/cmd/testdata/script/usage.txtar @@ -0,0 +1,22 @@ +# Given: a CLI whose root command takes a subcommand +# When: its help is shown +# Then: the use line is the single-line form, not the two-line one cobra defaults to +exec flagsmith --help +stdout 'flagsmith \[command\] \[flags\]' +! stdout 'flagsmith \[flags\]\n' + +# Given: a leaf command +# When: its help is shown +# Then: it has a use line of its own +exec flagsmith flag list --help +stdout 'flagsmith flag list \[flags\]' + +# Given: `flag create`, which is a redirect towards `feature create` rather than a command +# When: it is invoked +# Then: it nudges towards the real command, without a usage block for one that does not exist +! exec flagsmith flag create brand-new +stderr 'feature create brand-new' +! stderr 'Usage:' + +-- flagsmith.json -- +{"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN"}