From 11407f080e0b57b6a0d22a0f3b3c932ba6a86593 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 1 Aug 2026 16:35:20 +0100 Subject: [PATCH 01/34] test: run the CLI as a script, starting with `evaluate` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd_test.go asserts on command behaviour a fact at a time: a substring of the output here, a recording field on the fake there, and an `if err != nil` guard around each. Over half its assertion sites are that guard. A new case costs a subtest of the same shape, so the file grows faster than the behaviour it covers — `evaluate` alone arrived with 998 lines of it. A testscript script says the same things in one file: the config it starts from, the commands it runs, and the output, requests and exit status it expects. 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 — which retires captureOSStderr, and makes "the SDK's logger never reaches stderr" a one-line `! stderr .`. The fake now logs every request it serves, naming the credential by kind rather than value. That makes bodies, query parameters and call counts assertable without a recording field per endpoint, and makes the negatives free: no refetch after a write, no write before confirmation. Each case carries a Given/When/Then triple describing it in full, as the engine's test data does. TestEveryScriptCaseIsGivenWhenThen enforces both the triple and its self-sufficiency, so a case cannot come to rely on its neighbours. Setup pins FLAGSMITH_API_URL to the fake and mocks the keychain inside the impersonated binary. Nothing under test can reach api.flagsmith.com or the developer's keychain by forgetting a flag — which the Go tests only avoid by remembering to pass --api-url. beep boop --- go.mod | 2 + go.sum | 4 + internal/cmd/cmd_test.go | 519 ++---------------- internal/cmd/script_test.go | 218 ++++++++ .../testdata/script/evaluate-deadline.txtar | 10 + .../cmd/testdata/script/evaluate-empty.txtar | 22 + .../script/evaluate-environment.txtar | 20 + .../testdata/script/evaluate-failures.txtar | 10 + .../script/evaluate-one-feature.txtar | 20 + .../testdata/script/evaluate-test-flag.txtar | 40 ++ internal/cmd/testdata/script/evaluate.txtar | 94 ++++ 11 files changed, 499 insertions(+), 460 deletions(-) create mode 100644 internal/cmd/script_test.go create mode 100644 internal/cmd/testdata/script/evaluate-deadline.txtar create mode 100644 internal/cmd/testdata/script/evaluate-empty.txtar create mode 100644 internal/cmd/testdata/script/evaluate-environment.txtar create mode 100644 internal/cmd/testdata/script/evaluate-failures.txtar create mode 100644 internal/cmd/testdata/script/evaluate-one-feature.txtar create mode 100644 internal/cmd/testdata/script/evaluate-test-flag.txtar create mode 100644 internal/cmd/testdata/script/evaluate.txtar 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..0cf80bf 100644 --- a/internal/cmd/cmd_test.go +++ b/internal/cmd/cmd_test.go @@ -142,6 +142,7 @@ 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 @@ -268,6 +269,15 @@ type fakeFS struct { 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{ @@ -1367,11 +1377,58 @@ func newFakeInstance(t *testing.T) *fakeInstance { "body": string(body), }) }) - f.srv = httptest.NewServer(mux) - t.Cleanup(f.srv.Close) + 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) { @@ -5961,464 +6018,6 @@ func evalDoc(t *testing.T, out string) map[string]any { 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. diff --git a/internal/cmd/script_test.go b/internal/cmd/script_test.go new file mode 100644 index 0000000..9100478 --- /dev/null +++ b/internal/cmd/script_test.go @@ -0,0 +1,218 @@ +package cmd + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "slices" + "sort" + "strconv" + "strings" + "testing" + "time" + + "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() + Execute() + }, + }) +} + +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, + }, + }) +} + +// 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 { + 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 + } + for _, backref := range []string{"the same", " above", " again", "previous", "earlier"} { + if strings.Contains(strings.ToLower(l), backref) { + 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("HOME", env.WorkDir) + env.Setenv("XDG_CONFIG_HOME", filepath.Join(env.WorkDir, ".config")) + 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) < 2 { + ts.Fatalf("usage: fake [none]") + } + f := ts.Value("fake").(*fakeInstance) + f.mu.Lock() + defer f.mu.Unlock() + switch args[0] { + case "sdk-status": + code, err := strconv.Atoi(args[1]) + ts.Check(err) + f.sdkStatus = code + case "sdk-delay": + d, err := time.ParseDuration(args[1]) + ts.Check(err) + f.sdkDelay = d + case "sdk-flags": + flags := sdkFlagsFrom(defaultFeatures()) + if len(args) > 2 && args[2] == "none" { + flags = []map[string]any{} + } + f.sdkEnvFlags[args[1]] = flags + default: + ts.Fatalf("unknown fake setting %q", args[0]) + } +} + +// 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/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.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-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 + } + } +} From e6a98d45d53bd5e5a08b7fa3205fd85e9e1f4f73 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 1 Aug 2026 16:49:32 +0100 Subject: [PATCH 02/34] test: `evaluate`'s identity and environment-name cases become scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remaining two evaluate tests turn on things the request log already records — which identity was sent, with which traits and whether transiently, and which environment key reached the SDK API. Asserting on the log covers them without identifyBody(), and covers the trait typing more precisely: the wire value is in the transcript, so "42 is sent as an integer" is the literal `"trait_value":42` rather than a Go-side comparison after decoding. Two new script commands carry the fixtures these need. `cache` seeds the local name cache, resolving the path against the script's own HOME — going through cache.Merge would write to the developer's cache directory, since the builtin runs in the test process. `fake environments` and `fake sdk-identity` install the Admin and SDK fixtures. $MASTER_KEY joins the exported variables: an `env` lasts to the end of a script, so a case that clears the credential would otherwise decide what its neighbours start from. The back-reference check now matches whole words. It was reading "against" as "again" — and, once fixed, caught two descriptions of mine that leant on the case above them. beep boop --- internal/cmd/cmd_test.go | 362 ------------------ internal/cmd/script_test.go | 91 ++++- .../evaluate-environment-name-errors.txtar | 27 ++ .../evaluate-environment-name-lookup.txtar | 22 ++ .../script/evaluate-environment-name.txtar | 44 +++ .../script/evaluate-identity-rejections.txtar | 23 ++ .../testdata/script/evaluate-identity.txtar | 71 ++++ 7 files changed, 269 insertions(+), 371 deletions(-) create mode 100644 internal/cmd/testdata/script/evaluate-environment-name-errors.txtar create mode 100644 internal/cmd/testdata/script/evaluate-environment-name-lookup.txtar create mode 100644 internal/cmd/testdata/script/evaluate-environment-name.txtar create mode 100644 internal/cmd/testdata/script/evaluate-identity-rejections.txtar create mode 100644 internal/cmd/testdata/script/evaluate-identity.txtar diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go index 0cf80bf..32b8020 100644 --- a/internal/cmd/cmd_test.go +++ b/internal/cmd/cmd_test.go @@ -1631,12 +1631,6 @@ func sdkFlagsFrom(features []map[string]any) []map[string]any { 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() @@ -5996,362 +5990,6 @@ func TestEnvironmentKey(t *testing.T) { }) } -// 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 -} - -// 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) diff --git a/internal/cmd/script_test.go b/internal/cmd/script_test.go index 9100478..6d1687b 100644 --- a/internal/cmd/script_test.go +++ b/internal/cmd/script_test.go @@ -1,10 +1,13 @@ package cmd import ( + "encoding/json" "flag" "fmt" "os" "path/filepath" + "regexp" + "runtime" "slices" "sort" "strconv" @@ -12,6 +15,7 @@ import ( "testing" "time" + "github.com/Flagsmith/flagsmith-cli/v2/internal/cache" "github.com/rogpeppe/go-internal/testscript" "github.com/zalando/go-keyring" ) @@ -50,12 +54,17 @@ func TestScripts(t *testing.T) { RequireExplicitExec: true, Setup: setupScript, Cmds: map[string]func(*testscript.TestScript, bool, []string){ - "fake": cmdFake, - "dump": cmdDump, + "fake": cmdFake, + "dump": cmdDump, + "cache": cmdCache, }, }) } +// 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. @@ -106,10 +115,8 @@ func TestEveryScriptCaseIsGivenWhenThen(t *testing.T) { if !strings.HasPrefix(l, "# ") { continue } - for _, backref := range []string{"the same", " above", " again", "previous", "earlier"} { - if strings.Contains(strings.ToLower(l), backref) { - t.Errorf("%q refers to another case — describe this one in full", strings.TrimSpace(l)) - } + if backref.MatchString(strings.ToLower(l)) { + t.Errorf("%q refers to another case — describe this one in full", strings.TrimSpace(l)) } } } @@ -140,6 +147,11 @@ func setupScript(env *testscript.Env) error { // 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)) + + // 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("HOME", env.WorkDir) env.Setenv("XDG_CONFIG_HOME", filepath.Join(env.WorkDir, ".config")) return nil @@ -153,8 +165,11 @@ func setupScript(env *testscript.Env) error { // 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) < 2 { - ts.Fatalf("usage: fake [none]") + if neg || len(args) == 0 { + ts.Fatalf("usage: fake [value...]") + } + if len(args) < 2 && args[0] != "environments" { + ts.Fatalf("fake %s needs a value", args[0]) } f := ts.Value("fake").(*fakeInstance) f.mu.Lock() @@ -170,15 +185,73 @@ func cmdFake(ts *testscript.TestScript, neg bool, args []string) { f.sdkDelay = d case "sdk-flags": flags := sdkFlagsFrom(defaultFeatures()) - if len(args) > 2 && args[2] == "none" { + 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. + delete(f.sdkEnvFlags, args[1]) + return } 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 + f.sdkIdentityFlags[args[1]] = flags + case "environments": + // The two environments the Admin API knows about for project 101. + 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]) } } +// 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) < 2 || args[0] != "environments" { + ts.Fatalf("usage: cache environments =...") + } + f := ts.Value("fake").(*fakeInstance) + 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 + } + body, err := json.Marshal(map[string]*cache.Names{ + strings.TrimRight(f.srv.URL, "/"): {Environments: names}, + }) + ts.Check(err) + path := scriptCachePath(ts) + ts.Check(os.MkdirAll(filepath.Dir(path), 0o755)) + ts.Check(os.WriteFile(path, body, 0o600)) +} + +// scriptCachePath 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 scriptCachePath(ts *testscript.TestScript) string { + dir := filepath.Join(ts.Getenv("HOME"), ".cache") + switch { + case runtime.GOOS == "darwin": + dir = filepath.Join(ts.Getenv("HOME"), "Library", "Caches") + case ts.Getenv("XDG_CACHE_HOME") != "": + dir = ts.Getenv("XDG_CACHE_HOME") + } + return filepath.Join(dir, "flagsmith", "cache.json") +} + // 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. // 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-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 +} From d89ecfd5598757c586587012dbf02c044d9ff890 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 1 Aug 2026 16:55:41 +0100 Subject: [PATCH 03/34] test: `organisation` becomes a script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The create and update cases asserted on lastOrgBody; the request log carries the same body, so the boolean --force-2fa sets is checked as the wire value rather than after decoding. Delete asserted through orgByID on the fake — a following `organisation list` says the same thing in the CLI's own terms. `fake orgs` and `fake org-fields` set the organisations a script sees. Fixtures persist for the length of a script, as environment variables do, so each case installs the ones it needs rather than inheriting them. beep boop --- internal/cmd/cmd_test.go | 112 ------------------ internal/cmd/script_test.go | 42 ++++++- .../cmd/testdata/script/organisation.txtar | 73 ++++++++++++ 3 files changed, 114 insertions(+), 113 deletions(-) create mode 100644 internal/cmd/testdata/script/organisation.txtar diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go index 32b8020..c08174b 100644 --- a/internal/cmd/cmd_test.go +++ b/internal/cmd/cmd_test.go @@ -6269,118 +6269,6 @@ func TestProject(t *testing.T) { }) } -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) - } - } - }) - - 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) - } - }) - - 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 / 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) - } - - 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) - } - }) - - 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) - } - }) - - 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) - } - }) - - 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) - } - }) - - 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) - } - }) - - 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) - } - }) -} - func TestAPI(t *testing.T) { // echoJSON runs `flagsmith api api/v1/echo/ ` and returns the // decoded reflection of the request the fake saw. diff --git a/internal/cmd/script_test.go b/internal/cmd/script_test.go index 6d1687b..e0ba760 100644 --- a/internal/cmd/script_test.go +++ b/internal/cmd/script_test.go @@ -168,7 +168,7 @@ func cmdFake(ts *testscript.TestScript, neg bool, args []string) { if neg || len(args) == 0 { ts.Fatalf("usage: fake [value...]") } - if len(args) < 2 && args[0] != "environments" { + if len(args) < 2 && !slices.Contains([]string{"environments", "orgs"}, args[0]) { ts.Fatalf("fake %s needs a value", args[0]) } f := ts.Value("fake").(*fakeInstance) @@ -201,6 +201,31 @@ func cmdFake(ts *testscript.TestScript, neg bool, args []string) { flags := sdkFlagsFrom(defaultFeatures()) flags[1]["enabled"], flags[1]["feature_state_value"] = true, 99 f.sdkIdentityFlags[args[1]] = flags + case "orgs": + // fake orgs Acme=3 Beta=7 — or no pairs at all for an instance the + // credential can see no organisations in. + f.orgs = nil + 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) + f.orgs = append(f.orgs, map[string]any{"id": n, "name": name}) + } + case "org-fields": + // Extra API fields on Acme, for the cases about what --json passes through. + if len(f.orgs) == 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) + } + f.orgs[0][k] = scriptValue(v) + } case "environments": // The two environments the Admin API knows about for project 101. f.envs["101"] = []map[string]any{ @@ -212,6 +237,21 @@ func cmdFake(ts *testscript.TestScript, neg bool, args []string) { } } +// 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 +} + // cmdCache seeds the local name cache, for the cases that turn on what the CLI // already knows without asking the Admin API. // diff --git a/internal/cmd/testdata/script/organisation.txtar b/internal/cmd/testdata/script/organisation.txtar new file mode 100644 index 0000000..6988b2c --- /dev/null +++ b/internal/cmd/testdata/script/organisation.txtar @@ -0,0 +1,73 @@ +# 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 +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 From 5a562aeafda2db95d820aaa954daea00614c59dc Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 1 Aug 2026 16:58:41 +0100 Subject: [PATCH 04/34] test: `config` becomes a script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Source resolution is what this command is for, so the layered case now shows the whole document at once — file, environment variable and flag each winning where they should, ids carrying their cached names — rather than eight separate lookups into a parsed map. These scripts clear FLAGSMITH_API_URL. Setup sets it so that nothing under test can reach the real API by accident, but `config` reads no API at all and reports the variable as the apiUrl source, which is the opposite of what the default case is about. `cache` grew an instance argument and now merges, since a config names an instance of its own rather than the fake. beep boop --- internal/cmd/cmd_test.go | 183 ---------------------- internal/cmd/script_test.go | 38 ++++- internal/cmd/testdata/script/config.txtar | 131 ++++++++++++++++ 3 files changed, 162 insertions(+), 190 deletions(-) create mode 100644 internal/cmd/testdata/script/config.txtar diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go index c08174b..a0e35e2 100644 --- a/internal/cmd/cmd_test.go +++ b/internal/cmd/cmd_test.go @@ -2030,189 +2030,6 @@ func writeConfig(t *testing.T, dir, content string) string { 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 diff --git a/internal/cmd/script_test.go b/internal/cmd/script_test.go index e0ba760..63e5004 100644 --- a/internal/cmd/script_test.go +++ b/internal/cmd/script_test.go @@ -257,10 +257,14 @@ func scriptValue(s string) any { // // cache environments K2mVsGdXhZ8kQqZ9pJmNbJ=Production func cmdCache(ts *testscript.TestScript, neg bool, args []string) { - if neg || len(args) < 2 || args[0] != "environments" { - ts.Fatalf("usage: cache environments =...") + if neg || len(args) < 2 { + 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:] } - f := ts.Value("fake").(*fakeInstance) names := map[string]string{} for _, pair := range args[1:] { k, v, ok := strings.Cut(pair, "=") @@ -269,11 +273,31 @@ func cmdCache(ts *testscript.TestScript, neg bool, args []string) { } names[k] = v } - body, err := json.Marshal(map[string]*cache.Names{ - strings.TrimRight(f.srv.URL, "/"): {Environments: names}, - }) - ts.Check(err) + + // 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)) } 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" + } +} From 2a2c9ce5b6745129d71531f97388be8195c82457 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 1 Aug 2026 17:02:13 +0100 Subject: [PATCH 05/34] test: `api` and the --api-url flag become scripts /api/v1/echo/ reflects the request back, so what the CLI sent is already on stdout: these cases read it there rather than decoding it into a Go map first. Two of them go through --jq, because the fake's encoder escapes & as & and the raw body would have the scripts asserting on the fake's encoding rather than on the request. The flag-position cases compare against the first invocation's output with cmp, which is what "position changed behaviour" meant. beep boop --- internal/cmd/cmd_test.go | 194 ------------------ .../cmd/testdata/script/api-url-flag.txtar | 38 ++++ internal/cmd/testdata/script/api.txtar | 81 ++++++++ 3 files changed, 119 insertions(+), 194 deletions(-) create mode 100644 internal/cmd/testdata/script/api-url-flag.txtar create mode 100644 internal/cmd/testdata/script/api.txtar diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go index a0e35e2..0d143f2 100644 --- a/internal/cmd/cmd_test.go +++ b/internal/cmd/cmd_test.go @@ -1942,75 +1942,6 @@ func testBrowserLoginFlow(t *testing.T, prefix []string) { } } -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() @@ -6086,131 +6017,6 @@ func TestProject(t *testing.T) { }) } -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 - } - - 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) - } - }) - - 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) - } - }) - - 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) - } - }) - - 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) - } - }) - - 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"]) - } - }) - - 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"]) - } - }) - - 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"]) - } - }) - - 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) - } - }) - - 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) - } - }) - - 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) - } - }) - - 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) - } - }) -} - func TestFlagCreateIsNudge(t *testing.T) { // Given / When f := flagUpdateEnv(t) 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} From 8df2044645fd5380f10af5a625bf0ce0dddd85c0 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 1 Aug 2026 17:08:57 +0100 Subject: [PATCH 06/34] test: `segment` becomes a script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rule-tree cases asserted by walking a decoded body — rules[0].rules[0] .conditions[0].value — to check that IN travels as the packed string the API wants. The request log carries that string directly, so the script says what the wire looks like instead of how to navigate to it. Comparing `segment list` against a golden turned up an ordering bug in the fake: it holds segments in a map and returned them in whatever order the map iterated, so the two rows swapped between runs. Contains-assertions never saw it. The handler now sorts by id, as a paginated API would. `segment get` puts its nudge on stderr, which the merged-output helper hid. beep boop --- internal/cmd/cmd_test.go | 197 +-------------------- internal/cmd/testdata/script/segment.txtar | 113 ++++++++++++ 2 files changed, 118 insertions(+), 192 deletions(-) create mode 100644 internal/cmd/testdata/script/segment.txtar diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go index 0d143f2..b43ecc1 100644 --- a/internal/cmd/cmd_test.go +++ b/internal/cmd/cmd_test.go @@ -1145,6 +1145,11 @@ func newFake() *fakeInstance { 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) { @@ -4799,198 +4804,6 @@ func TestFlagIdentity(t *testing.T) { }) } -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", 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 From 14a1e7f650886c6abd91bbbbae34c5458da7cb99 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 1 Aug 2026 17:24:14 +0100 Subject: [PATCH 07/34] test: `flag list` becomes a script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The override views asserted position by index arithmetic — beta-optin's offset in the output being less than us-adults' — to check priority order. A golden shows the rows in order, so the ordering is the assertion rather than something inferred from it. Fixtures now come from the script: `fake features blob.json` and its neighbours read a JSON file out of the txtar, so the features a case turns on sit next to the case. The verbs that need existing fixture helpers delegate to them. That meant cmdFake could no longer hold the fake's lock across the whole switch, since those helpers take it themselves. Dropping the blanket lock left four fixture writes unsynchronised against the server reading them, which -race caught: they are wrapped individually now. flag list --segment's fan-out test stays in Go. It asserts that the per-feature reads overlap, and a script can see how many requests were made but not whether any two were in flight at once. beep boop --- internal/cmd/cmd_test.go | 525 +----------------- internal/cmd/script_test.go | 127 ++++- .../script/flag-list-environment-name.txtar | 46 ++ .../testdata/script/flag-list-overrides.txtar | 154 +++++ internal/cmd/testdata/script/flag-list.txtar | 96 ++++ 5 files changed, 413 insertions(+), 535 deletions(-) create mode 100644 internal/cmd/testdata/script/flag-list-environment-name.txtar create mode 100644 internal/cmd/testdata/script/flag-list-overrides.txtar create mode 100644 internal/cmd/testdata/script/flag-list.txtar diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go index b43ecc1..6a2fb2b 100644 --- a/internal/cmd/cmd_test.go +++ b/internal/cmd/cmd_test.go @@ -1735,95 +1735,6 @@ func TestServerSideKeyNeverEchoed(t *testing.T) { }) } -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 { @@ -2926,193 +2837,6 @@ func TestUnknownProjectNameErrors(t *testing.T) { } } -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) @@ -3151,69 +2875,6 @@ func TestFlagListSegmentFansOut(t *testing.T) { } } -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 @@ -3972,6 +3633,12 @@ func TestFlagSegmentByName(t *testing.T) { // override listings must skip. func withFeatureOverridesFixture(f *fakeInstance) { withSegmentOverride(f, true) + withFeatureOverridesRows(f) +} + +// withFeatureOverridesRows registers max_items' two segment overrides and the +// feature-states behind them, without touching the features list. +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}, @@ -3984,186 +3651,6 @@ func withFeatureOverridesFixture(f *fakeInstance) { ) } -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) { diff --git a/internal/cmd/script_test.go b/internal/cmd/script_test.go index 63e5004..d46999d 100644 --- a/internal/cmd/script_test.go +++ b/internal/cmd/script_test.go @@ -168,21 +168,24 @@ func cmdFake(ts *testscript.TestScript, neg bool, args []string) { if neg || len(args) == 0 { ts.Fatalf("usage: fake [value...]") } - if len(args) < 2 && !slices.Contains([]string{"environments", "orgs"}, args[0]) { + valueless := []string{"environments", "orgs", "feature-overrides", "workflow-gated", + "segment-override-missing", "edge-identities"} + if len(args) < 2 && !slices.Contains(valueless, args[0]) { ts.Fatalf("fake %s needs a value", args[0]) } f := ts.Value("fake").(*fakeInstance) - f.mu.Lock() - defer f.mu.Unlock() + // 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) - f.sdkStatus = code + set(func() { f.sdkStatus = code }) case "sdk-delay": d, err := time.ParseDuration(args[1]) ts.Check(err) - f.sdkDelay = d + set(func() { f.sdkDelay = d }) case "sdk-flags": flags := sdkFlagsFrom(defaultFeatures()) switch { @@ -191,20 +194,93 @@ func cmdFake(ts *testscript.TestScript, neg bool, args []string) { 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. - delete(f.sdkEnvFlags, args[1]) + set(func() { delete(f.sdkEnvFlags, args[1]) }) return } - f.sdkEnvFlags[args[1]] = flags + 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 - f.sdkIdentityFlags[args[1]] = flags + 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. + var items []map[string]any + ts.Check(json.Unmarshal([]byte(ts.ReadFile(args[1])), &items)) + set(func() { f.features["101"] = items }) + case "segment-override": + // max_items alone, with or without an override for segment 12. + set(func() { 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. + set(func() { withSegmentOverride(f, true) }) + withFeatureOverridesRows(f) + case "feature-segments": + // fake feature-segments 2 rows.json + id, err := strconv.Atoi(args[1]) + ts.Check(err) + var rows []map[string]any + ts.Check(json.Unmarshal([]byte(ts.ReadFile(args[2])), &rows)) + withFeatureSegments(f, id, rows...) + case "feature-states": + // fake feature-states 2 rows.json + id, err := strconv.Atoi(args[1]) + ts.Check(err) + var rows []map[string]any + ts.Check(json.Unmarshal([]byte(ts.ReadFile(args[2])), &rows)) + withFeatureStates(f, id, rows...) + 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 "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. - f.orgs = nil + var orgs []map[string]any for _, pair := range args[1:] { name, id, ok := strings.Cut(pair, "=") if !ok { @@ -212,11 +288,14 @@ func cmdFake(ts *testscript.TestScript, neg bool, args []string) { } n, err := strconv.Atoi(id) ts.Check(err) - f.orgs = append(f.orgs, map[string]any{"id": n, "name": name}) + 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. - if len(f.orgs) == 0 { + 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:] { @@ -224,14 +303,30 @@ func cmdFake(ts *testscript.TestScript, neg bool, args []string) { if !ok { ts.Fatalf("fake org-fields: %q is not key=value", pair) } - f.orgs[0][k] = scriptValue(v) + 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. - 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}, - } + 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]) } 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 From 0528f17bed5f0d621c764c1ccaaf63e9df4a5edc Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 1 Aug 2026 17:38:08 +0100 Subject: [PATCH 08/34] test: `flag get` becomes a script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detail views are compared whole rather than probed for one substring at a time, so the rows nobody asserted on — Description, Lifecycle stage — are covered too, and a null identity-override count reading 0 is visible as the row it renders rather than inferred from the output containing a "0" somewhere. The by-id cases move together into one script: naming a feature by id is one behaviour across get, update, delete and reorder, and each asserts the same thing, that the canonical name is what travels. beep boop --- internal/cmd/cmd_test.go | 292 ------------------ internal/cmd/testdata/script/flag-by-id.txtar | 34 ++ internal/cmd/testdata/script/flag-get.txtar | 128 ++++++++ 3 files changed, 162 insertions(+), 292 deletions(-) create mode 100644 internal/cmd/testdata/script/flag-by-id.txtar create mode 100644 internal/cmd/testdata/script/flag-get.txtar diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go index 6a2fb2b..a0c6d36 100644 --- a/internal/cmd/cmd_test.go +++ b/internal/cmd/cmd_test.go @@ -2875,131 +2875,6 @@ func TestFlagListSegmentFansOut(t *testing.T) { } } -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) @@ -3193,117 +3068,6 @@ func withSegmentOverride(f *fakeInstance, withOverride bool) { 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 @@ -3728,62 +3492,6 @@ func TestFlagUpdatePriority(t *testing.T) { }) } -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. 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-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 From 1e7d5a7ab3e3f79cbf454111057f824b99cb88a4 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 1 Aug 2026 17:49:27 +0100 Subject: [PATCH 09/34] test: `flag update`, enable/disable and priority become scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These asserted mostly on lastUpdate, walking into the decoded body to reach segment_overrides[0].value.type. The request log carries the body as it went out, so a script says what the wire looks like — which also makes "the current state rides along unchanged" one pattern rather than four field comparisons. The no-refetch cases count calls with grep -count. That needs somewhere to count from, since the log runs the length of a script, so `fake forget-requests` marks the start. `fake features-default` restores the usual two features: several of these fixtures replace the list with one feature, and the next case along needs the other one back. State carries between cases the way environment variables do, so each case sets up what it needs. $CACHE is exported for the case about what name resolution remembers. Spelling the path out in the script would have been darwin-only. beep boop --- internal/cmd/cmd_test.go | 522 ------------------ internal/cmd/script_test.go | 29 +- .../testdata/script/flag-enable-disable.txtar | 44 ++ .../script/flag-segment-by-name.txtar | 62 +++ .../script/flag-update-priority.txtar | 46 ++ .../script/flag-update-rejections.txtar | 36 ++ .../testdata/script/flag-update-segment.txtar | 66 +++ .../cmd/testdata/script/flag-update.txtar | 40 ++ 8 files changed, 316 insertions(+), 529 deletions(-) create mode 100644 internal/cmd/testdata/script/flag-enable-disable.txtar create mode 100644 internal/cmd/testdata/script/flag-segment-by-name.txtar create mode 100644 internal/cmd/testdata/script/flag-update-priority.txtar create mode 100644 internal/cmd/testdata/script/flag-update-rejections.txtar create mode 100644 internal/cmd/testdata/script/flag-update-segment.txtar create mode 100644 internal/cmd/testdata/script/flag-update.txtar diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go index a0c6d36..9845714 100644 --- a/internal/cmd/cmd_test.go +++ b/internal/cmd/cmd_test.go @@ -23,7 +23,6 @@ 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" @@ -2885,123 +2884,6 @@ func flagUpdateEnv(t *testing.T) *fakeInstance { 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() @@ -3068,333 +2950,6 @@ func withSegmentOverride(f *fakeInstance, withOverride bool) { f.features["101"] = []map[string]any{item} } -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) withFeatureOverridesRows(f) @@ -3415,83 +2970,6 @@ func withFeatureOverridesRows(f *fakeInstance) { ) } -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 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. diff --git a/internal/cmd/script_test.go b/internal/cmd/script_test.go index d46999d..0beb837 100644 --- a/internal/cmd/script_test.go +++ b/internal/cmd/script_test.go @@ -154,6 +154,10 @@ func setupScript(env *testscript.Env) error { env.Setenv("MASTER_KEY", masterKey) env.Setenv("HOME", env.WorkDir) env.Setenv("XDG_CONFIG_HOME", filepath.Join(env.WorkDir, ".config")) + + // $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, "")) return nil } @@ -169,7 +173,7 @@ func cmdFake(ts *testscript.TestScript, neg bool, args []string) { ts.Fatalf("usage: fake [value...]") } valueless := []string{"environments", "orgs", "feature-overrides", "workflow-gated", - "segment-override-missing", "edge-identities"} + "segment-override-missing", "edge-identities", "forget-requests", "features-default"} if len(args) < 2 && !slices.Contains(valueless, args[0]) { ts.Fatalf("fake %s needs a value", args[0]) } @@ -271,6 +275,13 @@ func cmdFake(ts *testscript.TestScript, neg bool, args []string) { } } }) + case "features-default": + // The project's usual two features, as newFake starts with. + set(func() { f.features["101"] = defaultFeatures() }) + 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": @@ -397,20 +408,24 @@ func cmdCache(ts *testscript.TestScript, neg bool, args []string) { ts.Check(os.WriteFile(path, body, 0o600)) } -// scriptCachePath is where the CLI under test keeps its name cache: what +// 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 scriptCachePath(ts *testscript.TestScript) string { - dir := filepath.Join(ts.Getenv("HOME"), ".cache") +func cachePathFor(home, xdg string) string { + dir := filepath.Join(home, ".cache") switch { case runtime.GOOS == "darwin": - dir = filepath.Join(ts.Getenv("HOME"), "Library", "Caches") - case ts.Getenv("XDG_CACHE_HOME") != "": - dir = ts.Getenv("XDG_CACHE_HOME") + dir = filepath.Join(home, "Library", "Caches") + 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. // 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-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"}}} From 7f15ec66840c4f60e0f18c1b6fcf55892f36b921 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 1 Aug 2026 17:54:50 +0100 Subject: [PATCH 10/34] test: `flag`'s identity overrides become scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core and edge identities split into a script each. They are two different sets of endpoints — a core identity is addressed by the id a lookup returns, an edge one by its identifier — and the cases that matter most are the ones asserting that only one of them was called. The request log shows both, so that is a `! grep` rather than a nil check on a recording field. The writes are compared as they went out. Their fields are marshalled in alphabetical order, which is worth knowing when reading these: enabled comes before feature, and feature_state_value after both. beep boop --- internal/cmd/cmd_test.go | 261 ------------------ .../testdata/script/flag-identity-core.txtar | 106 +++++++ .../testdata/script/flag-identity-edge.txtar | 49 ++++ 3 files changed, 155 insertions(+), 261 deletions(-) create mode 100644 internal/cmd/testdata/script/flag-identity-core.txtar create mode 100644 internal/cmd/testdata/script/flag-identity-edge.txtar diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go index 9845714..2e035cd 100644 --- a/internal/cmd/cmd_test.go +++ b/internal/cmd/cmd_test.go @@ -3216,267 +3216,6 @@ func TestUsageIsSingleLine(t *testing.T) { } } -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 withFeatures(f *fakeInstance) { f.features["101"] = []map[string]any{ {"id": 88, "name": "checkout-v2", "type": "STANDARD", "description": "New checkout flow", 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"}] From 84f99b12e090d720e04c50884fa91f8c50bd11ba Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 1 Aug 2026 17:58:33 +0100 Subject: [PATCH 11/34] test: `flag reorder`, `flag delete` and the usage lines become scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reorder is the one that gains most from a golden. It asserted the resulting order by comparing the offsets of two names in the output, and asserted the re-permuted request by walking into segment_overrides twice. Both are now the thing itself: a table with us-adults first, and the request body as it went out. The refusal case is worth reading — an override whose state row has gone missing must not be echoed back as a zero state, and `! grep update-flag-v2` says no write happened at all. The accessors these tests needed — featuresSeg, segmentsCalls, identityLookups and the rest — have no callers left, and go vet does not report unused functions, so they go too. beep boop --- internal/cmd/cmd_test.go | 309 ------------------ .../cmd/testdata/script/flag-delete.txtar | 27 ++ .../cmd/testdata/script/flag-reorder.txtar | 104 ++++++ internal/cmd/testdata/script/usage.txtar | 22 ++ 4 files changed, 153 insertions(+), 309 deletions(-) create mode 100644 internal/cmd/testdata/script/flag-delete.txtar create mode 100644 internal/cmd/testdata/script/flag-reorder.txtar create mode 100644 internal/cmd/testdata/script/usage.txtar diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go index 2e035cd..6152b4a 100644 --- a/internal/cmd/cmd_test.go +++ b/internal/cmd/cmd_test.go @@ -1513,14 +1513,6 @@ func (f *fakeInstance) featuresEnv() string { 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() @@ -1549,37 +1541,6 @@ func (f *fakeInstance) featureSegmentsPeak() int { 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() @@ -2950,13 +2911,6 @@ func withSegmentOverride(f *fakeInstance, withOverride bool) { f.features["101"] = []map[string]any{item} } -func withFeatureOverridesFixture(f *fakeInstance) { - withSegmentOverride(f, true) - withFeatureOverridesRows(f) -} - -// withFeatureOverridesRows registers max_items' two segment overrides and the -// feature-states behind them, without touching the features list. func withFeatureOverridesRows(f *fakeInstance) { withFeatureSegments(f, 2, map[string]any{"id": 1200, "segment": 57, "segment_name": "beta-optin", "priority": 0, "environment": 1}, @@ -2970,252 +2924,6 @@ func withFeatureOverridesRows(f *fakeInstance) { ) } -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 withFeatures(f *fakeInstance) { f.features["101"] = []map[string]any{ {"id": 88, "name": "checkout-v2", "type": "STANDARD", "description": "New checkout flow", @@ -4242,23 +3950,6 @@ func TestProject(t *testing.T) { }) } -func TestFlagCreateIsNudge(t *testing.T) { - // Given / When - f := flagUpdateEnv(t) - out, err := run("", "flag", "create", "brand-new") - - // 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) - } - _ = f -} - func TestAuthStatusHonoursConfigAPIURL(t *testing.T) { // Given isolateStorage(t) 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-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/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"} From f6992702459b19d2318210f89d73dcfba3aab9b3 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 1 Aug 2026 18:37:31 +0100 Subject: [PATCH 12/34] test: `feature` becomes a script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixture these read against is now a JSON file in the script, so the catalogue a case turns on — one standard feature, one multivariate, one archived — is visible next to the case rather than in a Go helper three hundred lines away. Loading it needed a conversion: encoding/json makes every number a float64, and the fake matches ids and counts against int, so a fixture written in a script would never be found by id. Whole numbers decode to int now. Worth knowing when reading the request assertions: bodies built from structs keep their field order, and bodies built from maps come out alphabetical. Both appear here. TestFalseyEnvSwitches keeps its second case. It drives a confirmation prompt and needs a terminal. beep boop --- internal/cmd/cmd_test.go | 464 ------------------ internal/cmd/script_test.go | 48 +- .../cmd/testdata/script/feature-search.txtar | 65 +++ .../cmd/testdata/script/feature-variant.txtar | 69 +++ .../cmd/testdata/script/feature-write.txtar | 99 ++++ internal/cmd/testdata/script/feature.txtar | 96 ++++ 6 files changed, 369 insertions(+), 472 deletions(-) create mode 100644 internal/cmd/testdata/script/feature-search.txtar create mode 100644 internal/cmd/testdata/script/feature-variant.txtar create mode 100644 internal/cmd/testdata/script/feature-write.txtar create mode 100644 internal/cmd/testdata/script/feature.txtar diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go index 6152b4a..d75f080 100644 --- a/internal/cmd/cmd_test.go +++ b/internal/cmd/cmd_test.go @@ -1513,13 +1513,6 @@ func (f *fakeInstance) featuresEnv() string { return f.lastFeatEnv } -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() @@ -2924,217 +2917,7 @@ func withFeatureOverridesRows(f *fakeInstance) { ) } -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) @@ -3155,253 +2938,6 @@ func TestFalseyEnvSwitches(t *testing.T) { }) } -// 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"}, diff --git a/internal/cmd/script_test.go b/internal/cmd/script_test.go index 0beb837..6063f78 100644 --- a/internal/cmd/script_test.go +++ b/internal/cmd/script_test.go @@ -211,8 +211,7 @@ func cmdFake(ts *testscript.TestScript, neg bool, args []string) { 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. - var items []map[string]any - ts.Check(json.Unmarshal([]byte(ts.ReadFile(args[1])), &items)) + 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. @@ -226,16 +225,12 @@ func cmdFake(ts *testscript.TestScript, neg bool, args []string) { // fake feature-segments 2 rows.json id, err := strconv.Atoi(args[1]) ts.Check(err) - var rows []map[string]any - ts.Check(json.Unmarshal([]byte(ts.ReadFile(args[2])), &rows)) - withFeatureSegments(f, id, rows...) + 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) - var rows []map[string]any - ts.Check(json.Unmarshal([]byte(ts.ReadFile(args[2])), &rows)) - withFeatureStates(f, id, rows...) + 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. @@ -343,6 +338,43 @@ func cmdFake(ts *testscript.TestScript, neg bool, args []string) { } } +// 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 { 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 From 9e4649776f483695f5cbf718243f7857729b43b1 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 1 Aug 2026 18:40:17 +0100 Subject: [PATCH 13/34] test: `environment` becomes a script Several of these turn on which requests were *not* made: `environment get` answers from the list row it already has, and names the project from the cache, so neither the environment nor the project is retrieved. That was three counter accessors on the fake; it is now two `! grep`s against the request log, next to the case that cares. --json and --jq do retrieve, because the retrieve serializer is richer than the list row, and the scripts pin that at exactly one call. `cache` now accepts a kind with no pairs, for the case that wants the name cache cold. beep boop --- internal/cmd/cmd_test.go | 283 ------------------ internal/cmd/script_test.go | 9 +- .../script/environment-document.txtar | 25 ++ .../cmd/testdata/script/environment-key.txtar | 34 +++ .../cmd/testdata/script/environment.txtar | 97 ++++++ 5 files changed, 163 insertions(+), 285 deletions(-) create mode 100644 internal/cmd/testdata/script/environment-document.txtar create mode 100644 internal/cmd/testdata/script/environment-key.txtar create mode 100644 internal/cmd/testdata/script/environment.txtar diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go index d75f080..8fb8ae9 100644 --- a/internal/cmd/cmd_test.go +++ b/internal/cmd/cmd_test.go @@ -1534,20 +1534,6 @@ func (f *fakeInstance) featureSegmentsPeak() int { return f.fsPeak } -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() @@ -2938,275 +2924,6 @@ func TestFalseyEnvSwitches(t *testing.T) { }) } -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) - } - }) -} - func TestProject(t *testing.T) { t.Run("list shows the organisation name", func(t *testing.T) { f := flagUpdateEnv(t) diff --git a/internal/cmd/script_test.go b/internal/cmd/script_test.go index 6063f78..59b23bd 100644 --- a/internal/cmd/script_test.go +++ b/internal/cmd/script_test.go @@ -273,6 +273,11 @@ func cmdFake(ts *testscript.TestScript, neg bool, args []string) { case "features-default": // The project's usual two features, as newFake starts with. set(func() { f.features["101"] = defaultFeatures() }) + 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. @@ -395,8 +400,8 @@ func scriptValue(s string) any { // // cache environments K2mVsGdXhZ8kQqZ9pJmNbJ=Production func cmdCache(ts *testscript.TestScript, neg bool, args []string) { - if neg || len(args) < 2 { - ts.Fatalf("usage: cache [-url=] =...") + if neg || len(args) == 0 { + ts.Fatalf("usage: cache [-url=] [=...]") } instance := ts.Value("fake").(*fakeInstance).srv.URL if strings.HasPrefix(args[0], "-url=") { 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 From 2124874dea4f6b718d8258e11e1e2c1eb10cc15c Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 1 Aug 2026 18:46:13 +0100 Subject: [PATCH 14/34] test: `project` becomes a script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The organisation-labelling cases are the interesting ones: a name resolved once labels every row from the cache, and a later `project get` adds no organisation fetch at all. That was a counter read before and after; it is now a `! grep` over the request log. Writing them turned up why the delete-by-id case says what it says. Given a warm name cache, `project delete 101` prints "acme-api (101)" — the bare id appears only when nothing can name it, so that case now clears the cache first. The Go test got the cold cache for free from a fresh fixture per subtest. TestProject keeps its confirmation-deadline case, which needs a terminal. beep boop --- internal/cmd/cmd_test.go | 298 --------------------- internal/cmd/script_test.go | 4 + internal/cmd/testdata/script/project.txtar | 162 +++++++++++ 3 files changed, 166 insertions(+), 298 deletions(-) create mode 100644 internal/cmd/testdata/script/project.txtar diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go index 8fb8ae9..939263d 100644 --- a/internal/cmd/cmd_test.go +++ b/internal/cmd/cmd_test.go @@ -1506,13 +1506,6 @@ func (f *fakeInstance) revokeCount() int { 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 -} - func (f *fakeInstance) featuresCalls() int { f.mu.Lock() defer f.mu.Unlock() @@ -2741,41 +2734,6 @@ func TestResolveCredentialRefreshesOnceUnderConcurrency(t *testing.T) { } } -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 TestFlagListSegmentFansOut(t *testing.T) { // Given f := flagUpdateEnv(t) @@ -2925,148 +2883,6 @@ func TestFalseyEnvSwitches(t *testing.T) { } 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) @@ -3087,120 +2903,6 @@ func TestProject(t *testing.T) { } }) - 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 TestAuthStatusHonoursConfigAPIURL(t *testing.T) { diff --git a/internal/cmd/script_test.go b/internal/cmd/script_test.go index 59b23bd..1393729 100644 --- a/internal/cmd/script_test.go +++ b/internal/cmd/script_test.go @@ -273,6 +273,10 @@ func cmdFake(ts *testscript.TestScript, neg bool, args []string) { 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 "server-keys": // fake server-keys K2mVsGdXhZ8kQqZ9pJmNbJ keys.json — the server-side // keys an environment already has. 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 From 44d5205c0bbe2b391fb9f1c5e0b10ec597fb2dfb Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 1 Aug 2026 18:53:06 +0100 Subject: [PATCH 15/34] test: the cross-cutting cases become scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --version, the bare-invocation nudge, warnings landing on stderr, --json as a global, and the server-side-key protections. The key cases are worth keeping an eye on: a server-side key must not appear in anything the CLI prints, however it arrived — from the environment, from the command line, or in an error the SDK API handed back. Each asserts on both streams. Making the positional rejection quote the ref back does fail them, so they bite. Two of these needed the environment taken away rather than set up: the nudge only appears when there are no credentials to find, and Setup provides one. With the last of the assertions on the fake's recording fields gone, the fields themselves are written and never read. They were the per-endpoint bookkeeping the request log replaced, so they go with it. beep boop --- internal/cmd/cmd_test.go | 269 +----------------- internal/cmd/testdata/script/cli.txtar | 57 ++++ .../cmd/testdata/script/server-side-key.txtar | 31 ++ 3 files changed, 95 insertions(+), 262 deletions(-) create mode 100644 internal/cmd/testdata/script/cli.txtar create mode 100644 internal/cmd/testdata/script/server-side-key.txtar diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go index 939263d..bb9ca4a 100644 --- a/internal/cmd/cmd_test.go +++ b/internal/cmd/cmd_test.go @@ -167,24 +167,17 @@ type fakeInstance struct { 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 + 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 - lastSegmentBody map[string]any // last segment create/update body + 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 @@ -203,16 +196,10 @@ type fakeInstance struct { 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 @@ -406,7 +393,6 @@ func newFake() *fakeInstance { 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 { @@ -446,7 +432,6 @@ func newFake() *fakeInstance { 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 { @@ -484,7 +469,6 @@ func newFake() *fakeInstance { 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 { @@ -546,7 +530,6 @@ func newFake() *fakeInstance { 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, @@ -583,7 +566,6 @@ func newFake() *fakeInstance { 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"} @@ -651,8 +633,6 @@ func newFake() *fakeInstance { 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) @@ -672,7 +652,6 @@ func newFake() *fakeInstance { var body map[string]any json.NewDecoder(r.Body).Decode(&body) f.mu.Lock() - f.lastDelete = body f.mu.Unlock() w.WriteHeader(http.StatusNoContent) }) @@ -705,7 +684,6 @@ func newFake() *fakeInstance { 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 { @@ -792,7 +770,6 @@ func newFake() *fakeInstance { 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{} } @@ -814,7 +791,6 @@ func newFake() *fakeInstance { 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 @@ -883,7 +859,6 @@ func newFake() *fakeInstance { 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{} } @@ -900,7 +875,6 @@ func newFake() *fakeInstance { 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)) } @@ -931,7 +905,6 @@ func newFake() *fakeInstance { 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) @@ -948,7 +921,6 @@ func newFake() *fakeInstance { 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 { @@ -1007,7 +979,6 @@ func newFake() *fakeInstance { 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) @@ -1024,7 +995,6 @@ func newFake() *fakeInstance { 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 { @@ -1069,7 +1039,6 @@ func newFake() *fakeInstance { 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 { @@ -1090,7 +1059,6 @@ func newFake() *fakeInstance { 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) { @@ -1159,7 +1127,6 @@ func newFake() *fakeInstance { 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 @@ -1192,7 +1159,6 @@ func newFake() *fakeInstance { 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() @@ -1592,81 +1558,6 @@ func (f *fakeInstance) refreshCount() int { 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)) - } - }) -} - // commandShapes are the two supported spellings of login/logout: // top-level and under `auth`. var commandShapes = []struct { @@ -1929,20 +1820,6 @@ func TestInitEmptyRefExitsCleanly(t *testing.T) { }) } -// --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 }) @@ -2419,24 +2296,6 @@ func TestInitReinitShowsDiffAndConfirms(t *testing.T) { } } -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 { @@ -2451,103 +2310,6 @@ func runSplit(stdin string, args ...string) (string, string, error) { 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) @@ -2925,23 +2687,6 @@ func TestAuthStatusHonoursConfigAPIURL(t *testing.T) { } } -func TestAuthStatusSeedsNameCache(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 { - 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) - } -} - func TestEnvMasterKey(t *testing.T) { // Given isolateStorage(t) 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/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} From 1dbb67bbc3bf3c4cf204fdec3dbf4ac056a86f2a Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 1 Aug 2026 19:04:15 +0100 Subject: [PATCH 16/34] test: the credential-precedence cases become scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Which credential wins, and what the CLI says about where it came from: an access token in the wrong variable, a server-side key in the wrong variable, the environment beating the keychain, and a Master API key beating an access token. The keychain travels between invocations as a file. The mock lives only as long as a process and cannot be enumerated, so $KEYCHAIN is seeded from a txtar section and written back afterwards, for the instances a script deals with. Execute returns on success and exits on failure, so a failed invocation's writes are not carried forward — enough for these, and worth knowing before writing a case that expects otherwise. `subst` expands variables in a fixture file in place. txtar bodies are copied out verbatim, so a config that has to name the fake writes $API. beep boop --- internal/cmd/cmd_test.go | 175 ------------------ internal/cmd/script_test.go | 100 ++++++++++ .../cmd/testdata/script/credentials.txtar | 77 ++++++++ 3 files changed, 177 insertions(+), 175 deletions(-) create mode 100644 internal/cmd/testdata/script/credentials.txtar diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go index bb9ca4a..c97ed64 100644 --- a/internal/cmd/cmd_test.go +++ b/internal/cmd/cmd_test.go @@ -2667,60 +2667,6 @@ func TestProject(t *testing.T) { } -func TestAuthStatusHonoursConfigAPIURL(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") - - // 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) - } -} - -func TestEnvMasterKey(t *testing.T) { - // Given - isolateStorage(t) - f := newFakeInstance(t) - 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) - } - 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) - } - } - - // When - tokenOut, err := run("", "auth", "token", "--api", f.srv.URL) - - // Then - if err != nil { - t.Fatalf("auth token: %v", err) - } - if strings.TrimSpace(tokenOut) != masterKey { - t.Errorf("auth token output = %q, want the master key", tokenOut) - } -} - -// 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 TestUnscopedCredentialNotSentToRedirectedHost(t *testing.T) { setup := func(t *testing.T) *fakeInstance { t.Helper() @@ -2848,100 +2794,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) @@ -2962,30 +2814,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/script_test.go b/internal/cmd/script_test.go index 1393729..c7660e3 100644 --- a/internal/cmd/script_test.go +++ b/internal/cmd/script_test.go @@ -15,6 +15,7 @@ import ( "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" @@ -40,11 +41,88 @@ func TestMain(m *testing.M) { // 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"), @@ -57,6 +135,7 @@ func TestScripts(t *testing.T) { "fake": cmdFake, "dump": cmdDump, "cache": cmdCache, + "subst": cmdSubst, }, }) } @@ -147,17 +226,20 @@ func setupScript(env *testscript.Env) error { // 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")) // $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 } @@ -399,6 +481,24 @@ func scriptValue(s string) any { 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. // diff --git a/internal/cmd/testdata/script/credentials.txtar b/internal/cmd/testdata/script/credentials.txtar new file mode 100644 index 0000000..9948a04 --- /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 '\$FLAGSMITH_API_KEY' + +# 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 '\$FLAGSMITH_API_KEY' + +# 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 '\$FLAGSMITH_API_KEY' +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"}] From c1383eb01aa2c3573d4f69f172434d2d951402a8 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 1 Aug 2026 19:08:25 +0100 Subject: [PATCH 17/34] test: `init` without a terminal becomes a script Everything init can be told on the command line: the flags that conflict, the ones that are required non-interactively, what it preserves from a config it is rewriting, and its refusal to touch a malformed file. Each case runs in a directory of its own. init writes flagsmith.json into the directory it runs from, so sharing one would make every case depend on what the last one wrote. What init created is read back through `flagsmith config --json`, which is also how a user would check. That covers the written file and its schema without a loader in the test. The interactive cases stay in Go: they drive huh's picker, which needs a terminal a script cannot give it. beep boop --- internal/cmd/cmd_test.go | 333 ------------------------ internal/cmd/script_test.go | 2 +- internal/cmd/testdata/script/init.txtar | 189 ++++++++++++++ 3 files changed, 190 insertions(+), 334 deletions(-) create mode 100644 internal/cmd/testdata/script/init.txtar diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go index c97ed64..332d08c 100644 --- a/internal/cmd/cmd_test.go +++ b/internal/cmd/cmd_test.go @@ -24,7 +24,6 @@ import ( "github.com/zalando/go-keyring" "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" ) @@ -1725,101 +1724,6 @@ func refID(r *config.Ref) int { 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) - } - }) -} - func TestSchemaURL(t *testing.T) { orig := version.Version t.Cleanup(func() { version.Version = orig }) @@ -1837,148 +1741,6 @@ func TestSchemaURL(t *testing.T) { } } -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 @@ -2125,101 +1887,6 @@ func TestInitEmptyProjectPromptsEnvironmentCreation(t *testing.T) { } } -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) diff --git a/internal/cmd/script_test.go b/internal/cmd/script_test.go index c7660e3..01b9942 100644 --- a/internal/cmd/script_test.go +++ b/internal/cmd/script_test.go @@ -255,7 +255,7 @@ func cmdFake(ts *testscript.TestScript, neg bool, args []string) { ts.Fatalf("usage: fake [value...]") } valueless := []string{"environments", "orgs", "feature-overrides", "workflow-gated", - "segment-override-missing", "edge-identities", "forget-requests", "features-default"} + "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]) } 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 From f522fff4bf5b6be1af7204ecece00f80c516f2a1 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 1 Aug 2026 19:19:50 +0100 Subject: [PATCH 18/34] fix: fall back to the numbered picker when stderr is not a terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The arrow-key selector puts stdin in raw mode and draws over stderr. It was gated on stdin alone, so `flagsmith init 2>log` — or init under any harness that captures stderr — drew a full-screen picker into the capture: escape sequences and repaints in the file, a half-drawn selector on screen. Both ends have to be a terminal for that UI to make sense. Where stderr is redirected the numbered fallback is what the situation wants, and it is already there for pipes on stdin. beep boop --- internal/cmd/prompts.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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. From 145a12896d9da896519254bcde339721d15a93cd Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 1 Aug 2026 19:24:55 +0100 Subject: [PATCH 19/34] test: `init`'s interactive cases become scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ttyin gives the CLI a pty, so it prompts; stderr is captured rather than a terminal, so it answers with the numbered picker. The answers are a file of choices, and blank lines accept the defaults. Splitting the streams turned up an inconsistency the merged-output helper hid. A fresh init keeps stdout empty — the prompt UI belongs on stderr — but a re-init writes its diff to stdout, so those two cases now assert against different streams. Worth a look; the scripts describe what it does today rather than what it ought to. The confirmation-deadline case stays in Go. It proves that time spent at a prompt is not counted against the invocation deadline, which needs an answer that arrives late; ttyin has the answer ready, so a script would pass whether or not the property held. beep boop --- internal/cmd/cmd_test.go | 235 ------------------ internal/cmd/script_test.go | 5 + internal/cmd/testdata/script/confirm.txtar | 15 ++ .../testdata/script/init-interactive.txtar | 156 ++++++++++++ 4 files changed, 176 insertions(+), 235 deletions(-) create mode 100644 internal/cmd/testdata/script/confirm.txtar create mode 100644 internal/cmd/testdata/script/init-interactive.txtar diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go index 332d08c..c769cea 100644 --- a/internal/cmd/cmd_test.go +++ b/internal/cmd/cmd_test.go @@ -1706,17 +1706,6 @@ func fakeTTY(t *testing.T) { 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 @@ -1760,209 +1749,6 @@ func TestPromptSelfGuardsWithoutTTY(t *testing.T) { } } -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 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 runSplit(stdin string, args ...string) (string, string, error) { resetFlags() if args == nil { @@ -2290,27 +2076,6 @@ func withFeatureOverridesRows(f *fakeInstance) { ) } -func TestFalseyEnvSwitches(t *testing.T) { - 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) - } - }) -} - func TestProject(t *testing.T) { t.Run("time at a confirmation does not count against the deadline", func(t *testing.T) { // Given diff --git a/internal/cmd/script_test.go b/internal/cmd/script_test.go index 01b9942..2d13449 100644 --- a/internal/cmd/script_test.go +++ b/internal/cmd/script_test.go @@ -359,6 +359,11 @@ func cmdFake(ts *testscript.TestScript, neg bool, args []string) { // 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. diff --git a/internal/cmd/testdata/script/confirm.txtar b/internal/cmd/testdata/script/confirm.txtar new file mode 100644 index 0000000..df97c7b --- /dev/null +++ b/internal/cmd/testdata/script/confirm.txtar @@ -0,0 +1,15 @@ +# Confirmations need a terminal to ask on, so these run under ttyin. + +# 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 — 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\.' +env FLAGSMITH_NO_INPUT= + +-- flagsmith.json -- +{"project": 101} +-- decline -- +n diff --git a/internal/cmd/testdata/script/init-interactive.txtar b/internal/cmd/testdata/script/init-interactive.txtar new file mode 100644 index 0000000..e92faec --- /dev/null +++ b/internal/cmd/testdata/script/init-interactive.txtar @@ -0,0 +1,156 @@ +# `init` with a terminal. +# Interactive prompts tested via bubbletea's accessible mode. + +# 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 +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 From a01adb7222106204bde238068610a9f93560d018 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 1 Aug 2026 19:41:42 +0100 Subject: [PATCH 20/34] test: give the fake instance a file of its own cmd_test.go was two thirds fake: a stub of the whole Flagsmith API, now shared with the scripts, sitting in the file named after the tests that used to be its only caller. It moves to fake_test.go, leaving cmd_test.go as the fourteen tests a script cannot express plus the harness they run through. newFake registered fifty-odd routes in one function. It now calls a mount per resource, so the handlers for a command's endpoints sit together and the largest of them is a screen or two rather than a thousand lines. The authorization check the handlers shared was a closure inside it, and is now a function beside them. beep boop --- internal/cmd/cmd_test.go | 1540 +--------------------------------- internal/cmd/fake_test.go | 1638 +++++++++++++++++++++++++++++++++++++ 2 files changed, 1658 insertions(+), 1520 deletions(-) create mode 100644 internal/cmd/fake_test.go diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go index c769cea..3445d67 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" @@ -24,7 +21,6 @@ import ( "github.com/zalando/go-keyring" "github.com/Flagsmith/flagsmith-cli/v2/internal/auth" - "github.com/Flagsmith/flagsmith-cli/v2/internal/config" "github.com/Flagsmith/flagsmith-cli/v2/internal/version" ) @@ -60,6 +56,9 @@ func isolateStorage(t *testing.T) { t.Setenv("AppData", tmp) } +// resetFlags clears package-level flag state that would otherwise leak +// between Execute calls on the shared rootCmd. + // resetFlags clears package-level flag state that would otherwise leak // between Execute calls on the shared rootCmd. func resetFlags() { @@ -86,6 +85,10 @@ func resetFlags() { evalTraitFlags = nil } +// setEnvCred exports a credential variable host-scoped to url, as a +// self-hosted user must: the unscoped variable is trusted only for the +// default SaaS host. + // setEnvCred exports a credential variable host-scoped to url, as a // self-hosted user must: the unscoped variable is trusted only for the // default SaaS host. @@ -121,6 +124,8 @@ func runWithStdin(stdin io.Reader, args ...string) (string, error) { return buf.String(), err } +// delayedReader stalls the first Read — a human thinking at a prompt. + // delayedReader stalls the first Read — a human thinking at a prompt. type delayedReader struct { delay time.Duration @@ -136,1426 +141,6 @@ func (d *delayedReader) Read(p []byte) (int, error) { // 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 - 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() - 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.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.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) - }) - 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 - } - var body map[string]any - json.NewDecoder(r.Body).Decode(&body) - f.mu.Lock() - 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() - 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() - 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) - }) - // 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) - }) - // 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) - }) - // 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}) - }) - // 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), - }) - }) - 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 -} // commandShapes are the two supported spellings of login/logout: // top-level and under `auth`. @@ -1567,6 +152,8 @@ var commandShapes = []struct { {"auth alias", []string{"auth"}}, } +// shapeArgs prepends a shape prefix to a command line. + // shapeArgs prepends a shape prefix to a command line. func shapeArgs(prefix []string, args ...string) []string { return append(append([]string{}, prefix...), args...) @@ -1706,13 +293,6 @@ func fakeTTY(t *testing.T) { t.Cleanup(func() { stdinIsTTY = orig }) } -func refID(r *config.Ref) int { - if r == nil { - return 0 - } - return r.ID -} - func TestSchemaURL(t *testing.T) { orig := version.Version t.Cleanup(func() { version.Version = orig }) @@ -1853,6 +433,10 @@ func TestBrowserLoginRefusesNoInput(t *testing.T) { } } +// --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. + // --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. @@ -1987,95 +571,6 @@ func TestFlagListSegmentFansOut(t *testing.T) { } } -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) { - 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}}, - ) -} - func TestProject(t *testing.T) { t.Run("time at a confirmation does not count against the deadline", func(t *testing.T) { // Given @@ -2144,6 +639,8 @@ func TestUnscopedCredentialNotSentToRedirectedHost(t *testing.T) { }) } +// The bearer variable is withheld from a redirected host like the master key. + // The bearer variable is withheld from a redirected host like the master key. func TestUnscopedAccessTokenNotSentToRedirectedHost(t *testing.T) { // Given @@ -2165,6 +662,9 @@ func TestUnscopedAccessTokenNotSentToRedirectedHost(t *testing.T) { } } +// The SDK credential is scoped to the SDK surface: sdkApiUrl's host, which is +// where that key is sent — not the Admin host, which can differ. + // The SDK credential is scoped to the SDK surface: sdkApiUrl's host, which is // where that key is sent — not the Admin host, which can differ. func TestSDKKeyScopesToSDKSurface(t *testing.T) { diff --git a/internal/cmd/fake_test.go b/internal/cmd/fake_test.go new file mode 100644 index 0000000..6de4be2 --- /dev/null +++ b/internal/cmd/fake_test.go @@ -0,0 +1,1638 @@ +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). + +// 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). + +// 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). + +// 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. + +// 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. + +// 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. + +// 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. + +// 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. + +// 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. + +// 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. + +// 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. + +// 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. + +// 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. + +// 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. + +// 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 +} + +// commandShapes are the two supported spellings of login/logout: +// top-level and under `auth`. + +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. + +// 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. + +// 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. + +// 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. + +// 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. + +// 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. + +// 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 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 + } + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + f.mu.Lock() + f.mu.Unlock() + 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), + }) + }) +} From 47aeb49c7d68bd1e81ac064d31fae8f0ba0de44b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:51:26 +0000 Subject: [PATCH 21/34] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- internal/cmd/testdata/script/flag-get.txtar | 4 ++-- internal/cmd/testdata/script/flag-update.txtar | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/cmd/testdata/script/flag-get.txtar b/internal/cmd/testdata/script/flag-get.txtar index 553c331..efc6221 100644 --- a/internal/cmd/testdata/script/flag-get.txtar +++ b/internal/cmd/testdata/script/flag-get.txtar @@ -95,7 +95,7 @@ stdout '12' [] -- expect-detail -- Feature max_items -Description +Description Type standard State off Value 25 @@ -114,7 +114,7 @@ false false -- expect-null-counts -- Feature edgeflag -Description +Description Type standard State on Value x diff --git a/internal/cmd/testdata/script/flag-update.txtar b/internal/cmd/testdata/script/flag-update.txtar index eef2625..60e6823 100644 --- a/internal/cmd/testdata/script/flag-update.txtar +++ b/internal/cmd/testdata/script/flag-update.txtar @@ -24,7 +24,7 @@ stderr 'Set max_items to "25"' {"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN"} -- expect-detail -- Feature max_items -Description +Description Type standard State on Value 25 From 9ea3375827b3da4861ee136a0e184b8eecf36630 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 1 Aug 2026 19:58:45 +0100 Subject: [PATCH 22/34] test: keep the script goldens byte for byte, on every platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pre-commit stripped trailing whitespace from the goldens and pushed the result, so CI compared the CLI's padded table output against unpadded expectations and every platform failed. The goldens are the output byte for byte — the last column is padded — so they are excluded from that hook and from end-of-file-fixer. The two scripts driving a pty skip where testscript has none, which is everywhere but linux and darwin. On Windows os.UserCacheDir reads %LocalAppData% rather than $HOME, so the name cache the CLI wrote was not the one the scripts read. Setup points both it and %AppData% at the script's own directory. Removing the write-only recording fields left a lock/unlock pair with nothing between it, which staticcheck flags. beep boop --- .pre-commit-config.yaml | 2 ++ internal/cmd/fake_test.go | 4 ---- internal/cmd/script_test.go | 7 +++++++ internal/cmd/testdata/script/confirm.txtar | 2 ++ internal/cmd/testdata/script/flag-get.txtar | 4 ++-- internal/cmd/testdata/script/flag-update.txtar | 2 +- internal/cmd/testdata/script/init-interactive.txtar | 2 ++ 7 files changed, 16 insertions(+), 7 deletions(-) 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/internal/cmd/fake_test.go b/internal/cmd/fake_test.go index 6de4be2..5c0c1a7 100644 --- a/internal/cmd/fake_test.go +++ b/internal/cmd/fake_test.go @@ -913,10 +913,6 @@ func (f *fakeInstance) mountFlags(mux *http.ServeMux) { w.WriteHeader(http.StatusNotFound) return } - var body map[string]any - json.NewDecoder(r.Body).Decode(&body) - f.mu.Lock() - f.mu.Unlock() w.WriteHeader(http.StatusNoContent) }) } diff --git a/internal/cmd/script_test.go b/internal/cmd/script_test.go index 2d13449..0f7bc81 100644 --- a/internal/cmd/script_test.go +++ b/internal/cmd/script_test.go @@ -235,6 +235,9 @@ func setupScript(env *testscript.Env) error { 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. @@ -562,6 +565,10 @@ func cachePathFor(home, xdg string) string { 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 } diff --git a/internal/cmd/testdata/script/confirm.txtar b/internal/cmd/testdata/script/confirm.txtar index df97c7b..568f85e 100644 --- a/internal/cmd/testdata/script/confirm.txtar +++ b/internal/cmd/testdata/script/confirm.txtar @@ -1,5 +1,7 @@ # 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 — a falsey value switches the behaviour off rather than merely being set diff --git a/internal/cmd/testdata/script/flag-get.txtar b/internal/cmd/testdata/script/flag-get.txtar index efc6221..553c331 100644 --- a/internal/cmd/testdata/script/flag-get.txtar +++ b/internal/cmd/testdata/script/flag-get.txtar @@ -95,7 +95,7 @@ stdout '12' [] -- expect-detail -- Feature max_items -Description +Description Type standard State off Value 25 @@ -114,7 +114,7 @@ false false -- expect-null-counts -- Feature edgeflag -Description +Description Type standard State on Value x diff --git a/internal/cmd/testdata/script/flag-update.txtar b/internal/cmd/testdata/script/flag-update.txtar index 60e6823..eef2625 100644 --- a/internal/cmd/testdata/script/flag-update.txtar +++ b/internal/cmd/testdata/script/flag-update.txtar @@ -24,7 +24,7 @@ stderr 'Set max_items to "25"' {"project": 101, "environment": "WqXhZk8sVY3dGgTqZ9pJmN"} -- expect-detail -- Feature max_items -Description +Description Type standard State on Value 25 diff --git a/internal/cmd/testdata/script/init-interactive.txtar b/internal/cmd/testdata/script/init-interactive.txtar index e92faec..935d756 100644 --- a/internal/cmd/testdata/script/init-interactive.txtar +++ b/internal/cmd/testdata/script/init-interactive.txtar @@ -1,6 +1,8 @@ # `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 From 36ff44f406f86f8ef269e76dc24b95d54ca9a43e Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 1 Aug 2026 20:04:15 +0100 Subject: [PATCH 23/34] test: check the script goldens out with LF everywhere Windows checked them out with CRLF, so every cmp compared the CLI's LF output against CRLF expectations and twenty-five scripts failed on lines that render identically. The stored objects were LF all along; it was the working-tree conversion. Marked text rather than binary, so they still diff. beep boop --- .gitattributes | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitattributes 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 From 73f260835bb21cc027568bc36684a83ba132d5f3 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 1 Aug 2026 20:10:21 +0100 Subject: [PATCH 24/34] test: drop the doc comments duplicated by the file split Splitting the fake out walked back from each declaration to pick up its doc comment, but the preceding block already ended with those lines, so twenty comments were written twice. The detached copy is not godoc and says nothing the attached one does not. beep boop --- internal/cmd/fake_test.go | 53 --------------------------------------- 1 file changed, 53 deletions(-) diff --git a/internal/cmd/fake_test.go b/internal/cmd/fake_test.go index 5c0c1a7..cb01a47 100644 --- a/internal/cmd/fake_test.go +++ b/internal/cmd/fake_test.go @@ -83,9 +83,6 @@ type fakeInstance struct { nextServerKeyID int } -// envByAPIKey finds a stored environment by its client-side key, returning its -// project key too (caller holds the lock). - // 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) { @@ -108,8 +105,6 @@ func (f *fakeInstance) orgByID(id int) map[string]any { return nil } -// projectByID finds a stored project across all orgs (caller holds the lock). - // 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 { @@ -122,8 +117,6 @@ func (f *fakeInstance) projectByID(id int) map[string]any { return nil } -// featureByID finds a stored feature item by id (caller holds the lock). - // 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] { @@ -134,8 +127,6 @@ func (f *fakeInstance) featureByID(project string, id int) map[string]any { return nil } -// fakeFS is a stored identity feature-state in the fake backend. - // fakeFS is a stored identity feature-state in the fake backend. type fakeFS struct { id int @@ -150,10 +141,6 @@ func newFakeInstance(t *testing.T) *fakeInstance { 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. - // 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. @@ -226,10 +213,6 @@ func newFake() *fakeInstance { 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. - // 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. @@ -254,8 +237,6 @@ func (f *fakeInstance) record(next http.Handler) http.Handler { }) } -// credKind names the credential a request carried, without echoing it. - // credKind names the credential a request carried, without echoing it. func credKind(r *http.Request) string { switch { @@ -273,8 +254,6 @@ func credKind(r *http.Request) string { return "" } -// requests returns the logged requests in the order they arrived. - // requests returns the logged requests in the order they arrived. func (f *fakeInstance) requests() []string { f.mu.Lock() @@ -282,9 +261,6 @@ func (f *fakeInstance) requests() []string { 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. - // 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) { @@ -336,9 +312,6 @@ func (f *fakeInstance) applyFlagUpdate(body map[string]any) { } } -// scalarFromWire turns an update-flag-v2 {type,value} into the bare scalar the -// features list would report. - // scalarFromWire turns an update-flag-v2 {type,value} into the bare scalar the // features list would report. func scalarFromWire(val map[string]any) any { @@ -367,8 +340,6 @@ func (f *fakeInstance) featuresCalls() int { return f.featListCalls } -// featureSegmentsCalls returns how many times feature-segments was hit. - // featureSegmentsCalls returns how many times feature-segments was hit. func (f *fakeInstance) featureSegmentsCalls() int { f.mu.Lock() @@ -376,9 +347,6 @@ func (f *fakeInstance) featureSegmentsCalls() int { return f.fsListCalls } -// featureSegmentsPeak returns the most feature-segments requests that were -// ever in flight at once. - // featureSegmentsPeak returns the most feature-segments requests that were // ever in flight at once. func (f *fakeInstance) featureSegmentsPeak() int { @@ -393,9 +361,6 @@ func (f *fakeInstance) organisationLists() int { return f.orgListCalls } -// defaultFeatures is the stock project features list (with per-environment -// state embedded) returned by the fake /features/ endpoint. - // defaultFeatures is the stock project features list (with per-environment // state embedded) returned by the fake /features/ endpoint. func defaultFeatures() []map[string]any { @@ -416,9 +381,6 @@ func defaultFeatures() []map[string]any { } } -// sdkFlagsFrom renders admin feature fixtures as the SDK API's flags payload — -// the shape `flagsmith evaluate` reads. - // 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 { @@ -480,9 +442,6 @@ func withFeatureSegments(f *fakeInstance, featureID int, rows ...map[string]any) f.featureSegments[strconv.Itoa(featureID)] = rows } -// withFeatureStates registers the admin featurestates rows the fake returns -// for one feature. - // withFeatureStates registers the admin featurestates rows the fake returns // for one feature. func withFeatureStates(f *fakeInstance, featureID int, rows ...map[string]any) { @@ -497,8 +456,6 @@ func withFeatureStates(f *fakeInstance, featureID int, rows ...map[string]any) { // 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. - // withWorkflowGating makes the update endpoints answer 403. func withWorkflowGating(f *fakeInstance) { f.mu.Lock() @@ -506,8 +463,6 @@ func withWorkflowGating(f *fakeInstance) { f.workflowGated = true } -// withMissingSegmentOverride makes delete-segment-override answer 404. - // withMissingSegmentOverride makes delete-segment-override answer 404. func withMissingSegmentOverride(f *fakeInstance) { f.mu.Lock() @@ -515,8 +470,6 @@ func withMissingSegmentOverride(f *fakeInstance) { f.segmentMissing = true } -// withEdgeIdentities makes the project report use_edge_identities. - // withEdgeIdentities makes the project report use_edge_identities. func withEdgeIdentities(f *fakeInstance) { f.mu.Lock() @@ -524,9 +477,6 @@ func withEdgeIdentities(f *fakeInstance) { f.useEdge = true } -// withFeatureSegmentDelay adds latency to feature-segments, so overlapping -// requests are observable in fsPeak. - // withFeatureSegmentDelay adds latency to feature-segments, so overlapping // requests are observable in fsPeak. func withFeatureSegmentDelay(f *fakeInstance, d time.Duration) { @@ -535,9 +485,6 @@ func withFeatureSegmentDelay(f *fakeInstance, d time.Duration) { f.fsDelay = d } -// withSegmentOverride sets project 101's features to a single max_items -// feature carrying an environment default and, optionally, a segment override. - // 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) { From 5d455c207fed8e2c90f868f2210cb9e00375e4e5 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 1 Aug 2026 20:11:47 +0100 Subject: [PATCH 25/34] test: give flagUpdateEnv back its own doc comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The split left commandShapes' comment attached to flagUpdateEnv and moved the var itself nowhere — it is still in cmd_test.go with a copy. The comment that belongs here is flagUpdateEnv's. beep boop --- internal/cmd/fake_test.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/internal/cmd/fake_test.go b/internal/cmd/fake_test.go index cb01a47..faaf909 100644 --- a/internal/cmd/fake_test.go +++ b/internal/cmd/fake_test.go @@ -420,9 +420,8 @@ func (f *fakeInstance) refreshCount() int { return f.tokenPosts } -// commandShapes are the two supported spellings of login/logout: -// top-level and under `auth`. - +// 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) From 23ba6aab180796a60a566b7557a7e17bfad7fa05 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 1 Aug 2026 20:13:26 +0100 Subject: [PATCH 26/34] test: let withSegmentOverride take the lock, like its neighbours It mutated f.features directly while the comment two declarations above says every field a handler reads is set through a locked setter. It was safe only because its one caller wrapped it, which is the kind of thing that stops being true. It locks itself now, so the callers do not have to know. beep boop --- internal/cmd/cmd_test.go | 20 +++++++------------- internal/cmd/fake_test.go | 2 ++ internal/cmd/script_test.go | 4 ++-- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go index 3445d67..e349428 100644 --- a/internal/cmd/cmd_test.go +++ b/internal/cmd/cmd_test.go @@ -142,25 +142,19 @@ func (d *delayedReader) Read(p []byte) (int, error) { // slice touches. Organisations answers to the master key, the env bearer // token, and the OAuth access token; users/me only to bearer credentials. -// 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. - // 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 { + 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) }) diff --git a/internal/cmd/fake_test.go b/internal/cmd/fake_test.go index faaf909..90e0c08 100644 --- a/internal/cmd/fake_test.go +++ b/internal/cmd/fake_test.go @@ -487,6 +487,8 @@ func withFeatureSegmentDelay(f *fakeInstance, d time.Duration) { // 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, diff --git a/internal/cmd/script_test.go b/internal/cmd/script_test.go index 0f7bc81..f8b1f1d 100644 --- a/internal/cmd/script_test.go +++ b/internal/cmd/script_test.go @@ -300,11 +300,11 @@ func cmdFake(ts *testscript.TestScript, neg bool, args []string) { set(func() { f.features["101"] = items }) case "segment-override": // max_items alone, with or without an override for segment 12. - set(func() { withSegmentOverride(f, args[1] == "on") }) + 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. - set(func() { withSegmentOverride(f, true) }) + withSegmentOverride(f, true) withFeatureOverridesRows(f) case "feature-segments": // fake feature-segments 2 rows.json From 9bd8b36fc75cd4bf5cd95f74ad5e41bb17a578d2 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 1 Aug 2026 20:14:32 +0100 Subject: [PATCH 27/34] test: skip directories when scanning the scripts ReadDir returns directories too, and reading one fails. There are none in testdata/script today, so this is about what happens when someone adds a subdirectory rather than about a bug you can hit now. beep boop --- internal/cmd/script_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/cmd/script_test.go b/internal/cmd/script_test.go index f8b1f1d..e4e7071 100644 --- a/internal/cmd/script_test.go +++ b/internal/cmd/script_test.go @@ -157,6 +157,9 @@ func TestEveryScriptCaseIsGivenWhenThen(t *testing.T) { 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 { From 969142e57231aae7b6557dd6521b203bc78c4a20 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 1 Aug 2026 20:17:54 +0100 Subject: [PATCH 28/34] test: say which arguments a fake fixture setting wanted Five of these name a target and a file. Given only the target they indexed past the end of the arguments and the script died with an index panic instead of a message pointing at the line. beep boop --- internal/cmd/script_test.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/cmd/script_test.go b/internal/cmd/script_test.go index e4e7071..5b4ddcf 100644 --- a/internal/cmd/script_test.go +++ b/internal/cmd/script_test.go @@ -265,6 +265,13 @@ func cmdFake(ts *testscript.TestScript, neg bool, args []string) { 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. From 0f55f601692dd9c6a82dc08f0703ea356ddb025d Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 1 Aug 2026 20:34:36 +0100 Subject: [PATCH 29/34] test: prove the declined confirmation deleted nothing The case asserted the abort message and stopped there, so it would have passed had the delete gone out anyway. Answering the prompt with y now fails it, which is what "nothing deleted" is supposed to mean. beep boop --- internal/cmd/testdata/script/confirm.txtar | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/cmd/testdata/script/confirm.txtar b/internal/cmd/testdata/script/confirm.txtar index 568f85e..dda39d2 100644 --- a/internal/cmd/testdata/script/confirm.txtar +++ b/internal/cmd/testdata/script/confirm.txtar @@ -4,11 +4,13 @@ # 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 — a falsey value switches the behaviour off rather than merely being set +# 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 -- From adc02f3e38f14213341ebf9db26bfeb72d3d4896 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 1 Aug 2026 20:36:17 +0100 Subject: [PATCH 30/34] test: name the whole credential variable, not its prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three cases asserted $FLAGSMITH_API_KEY where the CLI prints the host-scoped name in full. The prefix matches either spelling, so they would not have noticed the scoping being dropped — which is the thing that keeps a credential from following a redirected apiUrl. beep boop --- internal/cmd/testdata/script/credentials.txtar | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/cmd/testdata/script/credentials.txtar b/internal/cmd/testdata/script/credentials.txtar index 9948a04..6369426 100644 --- a/internal/cmd/testdata/script/credentials.txtar +++ b/internal/cmd/testdata/script/credentials.txtar @@ -25,7 +25,7 @@ stderr 'FLAGSMITH_ACCESS_TOKEN' env $API_KEY_VAR=$MASTER_KEY env $TOKEN_VAR=$BEARER_TOKEN exec flagsmith auth status -stdout '\$FLAGSMITH_API_KEY' +stdout '\$'$API_KEY_VAR # Given: a server-side key offered in the variable meant for Master API keys # When: the credential is described @@ -40,7 +40,7 @@ stderr 'FLAGSMITH_ENVIRONMENT_KEY' # Then: the environment wins, and says so env $API_KEY_VAR=$MASTER_KEY exec flagsmith auth status -stdout '\$FLAGSMITH_API_KEY' +stdout '\$'$API_KEY_VAR # Given: an expired OAuth credential in the keychain, and no environment credential # When: a command refreshes it @@ -57,7 +57,7 @@ env $API_KEY_VAR=$MASTER_KEY exec flagsmith auth status stdout 'Master API key' stdout 'Acme' -stdout '\$FLAGSMITH_API_KEY' +stdout '\$'$API_KEY_VAR exec flagsmith auth token stdout '^'$MASTER_KEY'$' From 03e667ac7a43e5d2f18221e08e1dcd4a275338a8 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 1 Aug 2026 20:39:58 +0100 Subject: [PATCH 31/34] test: let the stream-separation case set up its own instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Its Given describes two organisations and a project with an environment, and it established none of them — it ran on whatever the case before it left behind. That is the thing the Given is supposed to rule out. beep boop --- internal/cmd/testdata/script/init-interactive.txtar | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/cmd/testdata/script/init-interactive.txtar b/internal/cmd/testdata/script/init-interactive.txtar index 935d756..39d09f3 100644 --- a/internal/cmd/testdata/script/init-interactive.txtar +++ b/internal/cmd/testdata/script/init-interactive.txtar @@ -26,6 +26,9 @@ cd $WORK # 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 . From 32647f6f29efc784aa631a2f573d094f34b2460c Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 1 Aug 2026 20:40:23 +0100 Subject: [PATCH 32/34] test: give the org-alias case the one organisation it describes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It said "an instance with one organisation" while running against the two the case before it installed. The alias is what it is about, so the count never mattered to the assertion — but the description was wrong, and a description that can drift is worth less than none. beep boop --- internal/cmd/testdata/script/organisation.txtar | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/cmd/testdata/script/organisation.txtar b/internal/cmd/testdata/script/organisation.txtar index 6988b2c..c9c0829 100644 --- a/internal/cmd/testdata/script/organisation.txtar +++ b/internal/cmd/testdata/script/organisation.txtar @@ -8,6 +8,7 @@ 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' From 2a74ebf0ce6b8161a6309f19a46b728415caddd5 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 1 Aug 2026 20:45:28 +0100 Subject: [PATCH 33/34] test: drop the comments the split duplicated in cmd_test.go too The de-duplication pass ran over fake_test.go alone, and cmd_test.go came out of the same split with the same damage: six comments written twice, and one left describing fakeInstance, which had moved to the other file. The earlier pass also only matched copies separated by a single blank line. These were separated by more, so it walked past them. beep boop --- internal/cmd/cmd_test.go | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go index e349428..828aabf 100644 --- a/internal/cmd/cmd_test.go +++ b/internal/cmd/cmd_test.go @@ -56,9 +56,6 @@ func isolateStorage(t *testing.T) { t.Setenv("AppData", tmp) } -// resetFlags clears package-level flag state that would otherwise leak -// between Execute calls on the shared rootCmd. - // resetFlags clears package-level flag state that would otherwise leak // between Execute calls on the shared rootCmd. func resetFlags() { @@ -85,10 +82,6 @@ func resetFlags() { evalTraitFlags = nil } -// setEnvCred exports a credential variable host-scoped to url, as a -// self-hosted user must: the unscoped variable is trusted only for the -// default SaaS host. - // setEnvCred exports a credential variable host-scoped to url, as a // self-hosted user must: the unscoped variable is trusted only for the // default SaaS host. @@ -124,8 +117,6 @@ func runWithStdin(stdin io.Reader, args ...string) (string, error) { return buf.String(), err } -// delayedReader stalls the first Read — a human thinking at a prompt. - // delayedReader stalls the first Read — a human thinking at a prompt. type delayedReader struct { delay time.Duration @@ -138,10 +129,6 @@ 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. - // shapeArgs prepends a shape prefix to a command line. func shapeArgs(prefix []string, args ...string) []string { return append(append([]string{}, prefix...), args...) @@ -427,11 +414,7 @@ func TestBrowserLoginRefusesNoInput(t *testing.T) { } } -// --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. - -// --yes is authorization, not a liveness switch, so it must NOT block a +// --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) { @@ -633,8 +616,6 @@ func TestUnscopedCredentialNotSentToRedirectedHost(t *testing.T) { }) } -// The bearer variable is withheld from a redirected host like the master key. - // The bearer variable is withheld from a redirected host like the master key. func TestUnscopedAccessTokenNotSentToRedirectedHost(t *testing.T) { // Given @@ -656,9 +637,6 @@ func TestUnscopedAccessTokenNotSentToRedirectedHost(t *testing.T) { } } -// The SDK credential is scoped to the SDK surface: sdkApiUrl's host, which is -// where that key is sent — not the Admin host, which can differ. - // The SDK credential is scoped to the SDK surface: sdkApiUrl's host, which is // where that key is sent — not the Admin host, which can differ. func TestSDKKeyScopesToSDKSurface(t *testing.T) { From 1e9930a7308cd0c81bcfaff14f13018347259eb8 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 1 Aug 2026 20:49:01 +0100 Subject: [PATCH 34/34] test: authorisation, in the prose at least Three failure messages said authorization. The header, the OAuth endpoints and the fake's `authorized` helper keep the spelling their specs and identifiers give them. beep boop --- internal/cmd/cmd_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go index 828aabf..183dbc4 100644 --- a/internal/cmd/cmd_test.go +++ b/internal/cmd/cmd_test.go @@ -178,7 +178,7 @@ func testBrowserLoginFlow(t *testing.T, prefix []string) { time.Sleep(10 * time.Millisecond) } if q == nil { - t.Fatalf("login never printed an authorization URL; output: %q", out.String()) + 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) @@ -383,7 +383,7 @@ func TestBrowserLoginWithoutTTYNeverOpensBrowser(t *testing.T) { time.Sleep(10 * time.Millisecond) } if q == nil { - t.Fatalf("no authorization URL printed; output: %q", out.String()) + 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) @@ -447,7 +447,7 @@ func TestBrowserLoginNotBlockedByYes(t *testing.T) { time.Sleep(10 * time.Millisecond) } if q == nil { - t.Fatalf("--yes must not refuse login; no authorization URL printed; output: %q", out.String()) + 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)