package console import ( "context" "encoding/json" "net/http" "strings" "testing" "time" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/svcrole" ) func featuresURL(fx *consoleFixture, acct, app string) string { return fx.ts.URL + "/api/cli/apps/" + acct + "/" + app + "/features" } // featuresAnswer is the wire shape both doors answer with. type featuresAnswer struct { Account string `json:"account"` App string `json:"app"` Features map[string]string `json:"features"` SetBy string `json:"set_by"` SetAt string `json:"set_at"` Changed *bool `json:"changed"` } func decodeFeatures(t *testing.T, body []byte) featuresAnswer { t.Helper() var out featuresAnswer if err := json.Unmarshal(body, &out); err != nil { t.Fatalf("features body %s: %v", body, err) } return out } func TestAPIFeaturesSetMergesAuditsAndDoesNotAnnounce(t *testing.T) { fx, token := instanceFixture(t, "code-feat-set") status, out := postBody(t, featuresURL(fx, "bab", "titogo"), token, `{"set": {"sandbox": "on"}}`) if status != http.StatusOK { t.Fatalf("set = %d %s, want 200", status, out) } res := decodeFeatures(t, out) if res.Account != "bab" || res.App != "titogo" || res.Features["sandbox"] != "on" || res.Changed == nil || !*res.Changed { t.Fatalf("set body = %+v", res) } if res.SetBy != "alice@keymail.dev" || res.SetAt == "" { t.Fatalf("set body = %+v, want set_by alice and a set_at", res) } rec, err := release.GetFeatures(context.Background(), fx.rel, release.OpsAccount, "titogo") if err != nil || rec == nil || rec.Features["sandbox"] != "on" { t.Fatalf("record after set = %+v, %v", rec, err) } // A second set MERGES and an unset in the same call lands too. status, out = postBody(t, featuresURL(fx, "bab", "titogo"), token, `{"set": {"beta": "1"}, "unset": ["sandbox"], "message": "open the beta to testers"}`) if status != http.StatusOK { t.Fatalf("merge = %d %s, want 200", status, out) } res = decodeFeatures(t, out) if _, ok := res.Features["sandbox"]; ok || res.Features["beta"] != "1" { t.Fatalf("after merge = %+v, want beta only", res.Features) } events, err := fx.st.Events(OpsAccountID, "titogo", 10) if err != nil { t.Fatal(err) } var rows []Event for _, e := range events { if e.Action == "features" { rows = append(rows, e) } } if len(rows) != 2 { t.Fatalf("features audit rows = %q, want 2", rows) } // Newest first: the merge names both what it set and what it cleared. if !strings.Contains(rows[0].Detail, "beta=1") || !strings.Contains(rows[0].Detail, "unset sandbox") { t.Fatalf("merge audit detail = %q, want it to name beta=1 and the unset", rows[0]) } if rows[0].TargetType != "app" || rows[0].Target != "titogo" || rows[0].Message != "open the beta to testers" { t.Fatalf("merge audit structure = %+v", rows[0]) } if !strings.Contains(rows[1].Detail, "sandbox=on") { t.Fatalf("first audit detail = %q, want sandbox=on", rows[1]) } // No box consumes features.json — the app reads it off the console — // so a set must not send every box looking for a change it cannot see. if got := serial(t, fx); got != 0 { t.Fatalf("serial = %d, want 0 — features are not box-consumed", got) } } func TestAPIFeaturesNoOpSetWritesNoAuditRow(t *testing.T) { fx, token := instanceFixture(t, "code-feat-noop") postBody(t, featuresURL(fx, "bab", "titogo"), token, `{"set": {"sandbox": "on"}}`) status, out := postBody(t, featuresURL(fx, "bab", "titogo"), token, `{"set": {"sandbox": "on"}, "unset": ["absent"]}`) if status != http.StatusOK { t.Fatalf("no-op = %d %s, want 200", status, out) } if res := decodeFeatures(t, out); res.Changed == nil || *res.Changed { t.Fatalf("no-op body = %+v, want changed false", res) } events, _ := fx.st.Events(OpsAccountID, "titogo", 10) n := 0 for _, e := range events { if e.Action == "features" { n++ } } if n != 1 { t.Fatalf("features audit rows = %d, want 1 — a no-op must not audit", n) } } func TestAPIFeaturesGetAbsentIsEmptyNotError(t *testing.T) { fx, token := instanceFixture(t, "code-feat-get") status, out := getRetention(t, featuresURL(fx, "bab", "titogo"), token) if status != http.StatusOK { t.Fatalf("get = %d %s, want 200", status, out) } res := decodeFeatures(t, out) if res.Features == nil || len(res.Features) != 0 || res.SetAt != "" || res.Changed != nil { t.Fatalf("absent get = %s, want an empty (non-null) map and no set_at", out) } if !strings.Contains(string(out), `"features":{}`) { t.Fatalf("absent get = %s, want features spelled {} (an app ranges over it)", out) } } func TestAPIFeaturesBadShapesAre400AndWriteNothing(t *testing.T) { fx, token := instanceFixture(t, "code-feat-bad") for _, body := range []string{ `{"set": {"Sandbox": "on"}}`, `{"set": {"sandbox": ""}}`, `{"unset": ["bad key"]}`, `{}`, `{"set": {}, "unset": []}`, } { status, out := postBody(t, featuresURL(fx, "bab", "titogo"), token, body) if status != http.StatusBadRequest { t.Errorf("%s = %d %s, want 400", body, status, out) } } if rec, _ := release.GetFeatures(context.Background(), fx.rel, release.OpsAccount, "titogo"); rec != nil { t.Fatalf("a refused set wrote %+v", rec) } } // The point of the feature (#347): the app reads its flags off the // deployment block it already fetches with its CARLOS_INSTANCE_TOKEN, beside // managed_domains — no new call, no new credential, no restart. func TestAPIFeaturesRideTheDomainsDeploymentBlock(t *testing.T) { fx, member := instanceFixture(t, "code-feat-block") app := serviceToken(t, fx.st, OpsAccountID, "router", "titogo", svcrole.Instance) type block struct { Deployment struct { ManagedDomains bool `json:"managed_domains"` Features map[string]string `json:"features"` FeaturesSetAt string `json:"features_set_at"` Raw map[string]any `json:"-"` } `json:"deployment"` } read := func() (block, string) { code, _, out := rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo"), app, "") if code != http.StatusOK { t.Fatalf("domains list as the app = %d %s", code, out) } var b block if err := json.Unmarshal([]byte(out), &b); err != nil { t.Fatal(err) } return b, out } // Before anything is set: present and EMPTY, never null — an app tells // "no flags" from "could not read" by that. b, raw := read() if !b.Deployment.ManagedDomains { t.Fatal("managed_domains lost from the block") } if b.Deployment.Features == nil || !strings.Contains(raw, `"features":{}`) { t.Fatalf("block before any set = %s, want \"features\":{}", raw) } postBody(t, featuresURL(fx, "bab", "titogo"), member, `{"set": {"sandbox": "on"}}`) b, _ = read() if b.Deployment.Features["sandbox"] != "on" || b.Deployment.FeaturesSetAt == "" { t.Fatalf("block after set = %+v, want sandbox=on with a features_set_at", b.Deployment) } if _, err := time.Parse(time.RFC3339, b.Deployment.FeaturesSetAt); err != nil { t.Fatalf("features_set_at %q is not RFC3339: %v", b.Deployment.FeaturesSetAt, err) } // The per-host door carries the same block. attachDomain(t, fx, member, "flag.example.com", "go.tito.io") code, _, out := rawReq(t, http.MethodGet, domainGetURL(fx, "bab", "titogo", "flag.example.com"), app, "") if code != http.StatusOK { t.Fatalf("per-host get = %d %s", code, out) } var one block if err := json.Unmarshal([]byte(out), &one); err != nil { t.Fatal(err) } if one.Deployment.Features["sandbox"] != "on" { t.Fatalf("per-host block = %+v, want sandbox=on", one.Deployment) } } // A record the console cannot parse must not take the domains answer down // with it — the block reads features as null ("unknown"), which is neither // the empty map nor a stale value. func TestAPIFeaturesUnreadableRecordDegradesTheBlockToNull(t *testing.T) { fx, member := instanceFixture(t, "code-feat-degrade") if _, err := fx.rel.Put(context.Background(), release.FeaturesKey(release.OpsAccount, "titogo"), []byte(`{"schema":9}`), ""); err != nil { t.Fatal(err) } code, _, out := rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo"), member, "") if code != http.StatusOK { t.Fatalf("domains list with a bad features record = %d %s, want 200", code, out) } if !strings.Contains(out, `"features":null`) { t.Fatalf("block = %s, want \"features\":null for an unreadable record", out) } // And the dedicated read says so out loud rather than lying with {}. status, body := getRetention(t, featuresURL(fx, "bab", "titogo"), member) if status != http.StatusBadGateway { t.Fatalf("features get with a bad record = %d %s, want 502", status, body) } } // Roles: the app's own instance credential READS (it is the consumer) and // may never WRITE — a switch the app could flip on itself is a root env // flag by another name, the exact thing #347 exists to retire. Publish reads // too; only admin sets. func TestAPIFeaturesRoles(t *testing.T) { fx, _ := instanceFixture(t, "code-feat-roles") instance := serviceToken(t, fx.st, OpsAccountID, "router", "titogo", svcrole.Instance) publish := serviceToken(t, fx.st, OpsAccountID, "ci", "titogo", svcrole.Publish) operate := serviceToken(t, fx.st, OpsAccountID, "ops", "titogo", svcrole.Operate) admin := serviceToken(t, fx.st, OpsAccountID, "root", "titogo", svcrole.Admin) for name, tok := range map[string]string{"instance": instance, "publish": publish} { if status, out := getRetention(t, featuresURL(fx, "bab", "titogo"), tok); status != http.StatusOK { t.Errorf("%s GET = %d %s, want 200", name, status, out) } } if status, _ := getRetention(t, featuresURL(fx, "bab", "titogo"), operate); status != http.StatusForbidden { t.Errorf("operate GET = %d, want 403", status) } for name, tok := range map[string]string{"instance": instance, "publish": publish, "operate": operate} { if status, out := postBody(t, featuresURL(fx, "bab", "titogo"), tok, `{"set": {"sandbox": "on"}}`); status != http.StatusForbidden { t.Errorf("%s POST = %d %s, want 403", name, status, out) } } if status, out := postBody(t, featuresURL(fx, "bab", "titogo"), admin, `{"set": {"sandbox": "on"}}`); status != http.StatusOK { t.Errorf("admin POST = %d %s, want 200", status, out) } } // The console surface (#347): the app Settings page shows the flags the // platform serves, read-only, with the last change beside them — so a // member can see what an operator set without a terminal. Writes stay on // the CLI door, where they are role-gated and audited. func TestSettingsPageShowsFeatureFlags(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) freshApp(t, fx, "titogo") when := time.Date(2026, 8, 28, 9, 30, 0, 0, time.UTC) if _, _, err := release.UpdateFeatures(context.Background(), fx.rel, release.OpsAccount, "titogo", map[string]string{"sandbox": "on", "beta-ui": "v2"}, nil, "alice@keymail.dev", when); err != nil { t.Fatal(err) } status, _, body := get(t, c, fx.u("/apps/titogo/settings/features")) if status != http.StatusOK { t.Fatalf("settings page = %d:\n%s", status, body) } for _, want := range []string{ "
sandbox", "on",
"beta-ui", "v2",
"alice@keymail.dev",
"carlos features set --app titogo",
} {
if !strings.Contains(body, want) {
t.Fatalf("the feature flags card is missing %q:\n%s", want, body)
}
}
// Sorted by key: beta-ui before sandbox, whatever order the map ranges.
if strings.Index(body, "beta-ui") > strings.Index(body, "sandbox") {
t.Fatalf("flags are not key-sorted:\n%s", body)
}
// An app with none set says so, and still tells the member how one
// gets set.
freshApp(t, fx, "sprout")
_, _, body = get(t, c, fx.u("/apps/sprout/settings/features"))
if !strings.Contains(body, "No feature flags") || !strings.Contains(body, "carlos features set --app sprout") {
t.Fatalf("an app with no flags is not told so:\n%s", body)
}
}