| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "net/http" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/release" |
| 12 | "github.com/carlosframework/platform/internal/svcrole" |
| 13 | ) |
| 14 | |
| 15 | func featuresURL(fx *consoleFixture, acct, app string) string { |
| 16 | return fx.ts.URL + "/api/cli/apps/" + acct + "/" + app + "/features" |
| 17 | } |
| 18 | |
| 19 | // featuresAnswer is the wire shape both doors answer with. |
| 20 | type featuresAnswer struct { |
| 21 | Account string `json:"account"` |
| 22 | App string `json:"app"` |
| 23 | Features map[string]string `json:"features"` |
| 24 | SetBy string `json:"set_by"` |
| 25 | SetAt string `json:"set_at"` |
| 26 | Changed *bool `json:"changed"` |
| 27 | } |
| 28 | |
| 29 | func decodeFeatures(t *testing.T, body []byte) featuresAnswer { |
| 30 | t.Helper() |
| 31 | var out featuresAnswer |
| 32 | if err := json.Unmarshal(body, &out); err != nil { |
| 33 | t.Fatalf("features body %s: %v", body, err) |
| 34 | } |
| 35 | return out |
| 36 | } |
| 37 | |
| 38 | func TestAPIFeaturesSetMergesAuditsAndDoesNotAnnounce(t *testing.T) { |
| 39 | fx, token := instanceFixture(t, "code-feat-set") |
| 40 | status, out := postBody(t, featuresURL(fx, "bab", "titogo"), token, `{"set": {"sandbox": "on"}}`) |
| 41 | if status != http.StatusOK { |
| 42 | t.Fatalf("set = %d %s, want 200", status, out) |
| 43 | } |
| 44 | res := decodeFeatures(t, out) |
| 45 | if res.Account != "bab" || res.App != "titogo" || res.Features["sandbox"] != "on" || res.Changed == nil || !*res.Changed { |
| 46 | t.Fatalf("set body = %+v", res) |
| 47 | } |
| 48 | if res.SetBy != "alice@keymail.dev" || res.SetAt == "" { |
| 49 | t.Fatalf("set body = %+v, want set_by alice and a set_at", res) |
| 50 | } |
| 51 | rec, err := release.GetFeatures(context.Background(), fx.rel, release.OpsAccount, "titogo") |
| 52 | if err != nil || rec == nil || rec.Features["sandbox"] != "on" { |
| 53 | t.Fatalf("record after set = %+v, %v", rec, err) |
| 54 | } |
| 55 | |
| 56 | // A second set MERGES and an unset in the same call lands too. |
| 57 | status, out = postBody(t, featuresURL(fx, "bab", "titogo"), token, |
| 58 | `{"set": {"beta": "1"}, "unset": ["sandbox"], "message": "open the beta to testers"}`) |
| 59 | if status != http.StatusOK { |
| 60 | t.Fatalf("merge = %d %s, want 200", status, out) |
| 61 | } |
| 62 | res = decodeFeatures(t, out) |
| 63 | if _, ok := res.Features["sandbox"]; ok || res.Features["beta"] != "1" { |
| 64 | t.Fatalf("after merge = %+v, want beta only", res.Features) |
| 65 | } |
| 66 | |
| 67 | events, err := fx.st.Events(OpsAccountID, "titogo", 10) |
| 68 | if err != nil { |
| 69 | t.Fatal(err) |
| 70 | } |
| 71 | var rows []Event |
| 72 | for _, e := range events { |
| 73 | if e.Action == "features" { |
| 74 | rows = append(rows, e) |
| 75 | } |
| 76 | } |
| 77 | if len(rows) != 2 { |
| 78 | t.Fatalf("features audit rows = %q, want 2", rows) |
| 79 | } |
| 80 | // Newest first: the merge names both what it set and what it cleared. |
| 81 | if !strings.Contains(rows[0].Detail, "beta=1") || !strings.Contains(rows[0].Detail, "unset sandbox") { |
| 82 | t.Fatalf("merge audit detail = %q, want it to name beta=1 and the unset", rows[0]) |
| 83 | } |
| 84 | if rows[0].TargetType != "app" || rows[0].Target != "titogo" || rows[0].Message != "open the beta to testers" { |
| 85 | t.Fatalf("merge audit structure = %+v", rows[0]) |
| 86 | } |
| 87 | if !strings.Contains(rows[1].Detail, "sandbox=on") { |
| 88 | t.Fatalf("first audit detail = %q, want sandbox=on", rows[1]) |
| 89 | } |
| 90 | // No box consumes features.json — the app reads it off the console — |
| 91 | // so a set must not send every box looking for a change it cannot see. |
| 92 | if got := serial(t, fx); got != 0 { |
| 93 | t.Fatalf("serial = %d, want 0 — features are not box-consumed", got) |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | func TestAPIFeaturesNoOpSetWritesNoAuditRow(t *testing.T) { |
| 98 | fx, token := instanceFixture(t, "code-feat-noop") |
| 99 | postBody(t, featuresURL(fx, "bab", "titogo"), token, `{"set": {"sandbox": "on"}}`) |
| 100 | status, out := postBody(t, featuresURL(fx, "bab", "titogo"), token, `{"set": {"sandbox": "on"}, "unset": ["absent"]}`) |
| 101 | if status != http.StatusOK { |
| 102 | t.Fatalf("no-op = %d %s, want 200", status, out) |
| 103 | } |
| 104 | if res := decodeFeatures(t, out); res.Changed == nil || *res.Changed { |
| 105 | t.Fatalf("no-op body = %+v, want changed false", res) |
| 106 | } |
| 107 | events, _ := fx.st.Events(OpsAccountID, "titogo", 10) |
| 108 | n := 0 |
| 109 | for _, e := range events { |
| 110 | if e.Action == "features" { |
| 111 | n++ |
| 112 | } |
| 113 | } |
| 114 | if n != 1 { |
| 115 | t.Fatalf("features audit rows = %d, want 1 — a no-op must not audit", n) |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | func TestAPIFeaturesGetAbsentIsEmptyNotError(t *testing.T) { |
| 120 | fx, token := instanceFixture(t, "code-feat-get") |
| 121 | status, out := getRetention(t, featuresURL(fx, "bab", "titogo"), token) |
| 122 | if status != http.StatusOK { |
| 123 | t.Fatalf("get = %d %s, want 200", status, out) |
| 124 | } |
| 125 | res := decodeFeatures(t, out) |
| 126 | if res.Features == nil || len(res.Features) != 0 || res.SetAt != "" || res.Changed != nil { |
| 127 | t.Fatalf("absent get = %s, want an empty (non-null) map and no set_at", out) |
| 128 | } |
| 129 | if !strings.Contains(string(out), `"features":{}`) { |
| 130 | t.Fatalf("absent get = %s, want features spelled {} (an app ranges over it)", out) |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | func TestAPIFeaturesBadShapesAre400AndWriteNothing(t *testing.T) { |
| 135 | fx, token := instanceFixture(t, "code-feat-bad") |
| 136 | for _, body := range []string{ |
| 137 | `{"set": {"Sandbox": "on"}}`, |
| 138 | `{"set": {"sandbox": ""}}`, |
| 139 | `{"unset": ["bad key"]}`, |
| 140 | `{}`, |
| 141 | `{"set": {}, "unset": []}`, |
| 142 | } { |
| 143 | status, out := postBody(t, featuresURL(fx, "bab", "titogo"), token, body) |
| 144 | if status != http.StatusBadRequest { |
| 145 | t.Errorf("%s = %d %s, want 400", body, status, out) |
| 146 | } |
| 147 | } |
| 148 | if rec, _ := release.GetFeatures(context.Background(), fx.rel, release.OpsAccount, "titogo"); rec != nil { |
| 149 | t.Fatalf("a refused set wrote %+v", rec) |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | // The point of the feature (#347): the app reads its flags off the |
| 154 | // deployment block it already fetches with its CARLOS_INSTANCE_TOKEN, beside |
| 155 | // managed_domains — no new call, no new credential, no restart. |
| 156 | func TestAPIFeaturesRideTheDomainsDeploymentBlock(t *testing.T) { |
| 157 | fx, member := instanceFixture(t, "code-feat-block") |
| 158 | app := serviceToken(t, fx.st, OpsAccountID, "router", "titogo", svcrole.Instance) |
| 159 | |
| 160 | type block struct { |
| 161 | Deployment struct { |
| 162 | ManagedDomains bool `json:"managed_domains"` |
| 163 | Features map[string]string `json:"features"` |
| 164 | FeaturesSetAt string `json:"features_set_at"` |
| 165 | Raw map[string]any `json:"-"` |
| 166 | } `json:"deployment"` |
| 167 | } |
| 168 | read := func() (block, string) { |
| 169 | code, _, out := rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo"), app, "") |
| 170 | if code != http.StatusOK { |
| 171 | t.Fatalf("domains list as the app = %d %s", code, out) |
| 172 | } |
| 173 | var b block |
| 174 | if err := json.Unmarshal([]byte(out), &b); err != nil { |
| 175 | t.Fatal(err) |
| 176 | } |
| 177 | return b, out |
| 178 | } |
| 179 | |
| 180 | // Before anything is set: present and EMPTY, never null — an app tells |
| 181 | // "no flags" from "could not read" by that. |
| 182 | b, raw := read() |
| 183 | if !b.Deployment.ManagedDomains { |
| 184 | t.Fatal("managed_domains lost from the block") |
| 185 | } |
| 186 | if b.Deployment.Features == nil || !strings.Contains(raw, `"features":{}`) { |
| 187 | t.Fatalf("block before any set = %s, want \"features\":{}", raw) |
| 188 | } |
| 189 | |
| 190 | postBody(t, featuresURL(fx, "bab", "titogo"), member, `{"set": {"sandbox": "on"}}`) |
| 191 | b, _ = read() |
| 192 | if b.Deployment.Features["sandbox"] != "on" || b.Deployment.FeaturesSetAt == "" { |
| 193 | t.Fatalf("block after set = %+v, want sandbox=on with a features_set_at", b.Deployment) |
| 194 | } |
| 195 | if _, err := time.Parse(time.RFC3339, b.Deployment.FeaturesSetAt); err != nil { |
| 196 | t.Fatalf("features_set_at %q is not RFC3339: %v", b.Deployment.FeaturesSetAt, err) |
| 197 | } |
| 198 | |
| 199 | // The per-host door carries the same block. |
| 200 | attachDomain(t, fx, member, "flag.example.com", "go.tito.io") |
| 201 | code, _, out := rawReq(t, http.MethodGet, domainGetURL(fx, "bab", "titogo", "flag.example.com"), app, "") |
| 202 | if code != http.StatusOK { |
| 203 | t.Fatalf("per-host get = %d %s", code, out) |
| 204 | } |
| 205 | var one block |
| 206 | if err := json.Unmarshal([]byte(out), &one); err != nil { |
| 207 | t.Fatal(err) |
| 208 | } |
| 209 | if one.Deployment.Features["sandbox"] != "on" { |
| 210 | t.Fatalf("per-host block = %+v, want sandbox=on", one.Deployment) |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | // A record the console cannot parse must not take the domains answer down |
| 215 | // with it — the block reads features as null ("unknown"), which is neither |
| 216 | // the empty map nor a stale value. |
| 217 | func TestAPIFeaturesUnreadableRecordDegradesTheBlockToNull(t *testing.T) { |
| 218 | fx, member := instanceFixture(t, "code-feat-degrade") |
| 219 | if _, err := fx.rel.Put(context.Background(), release.FeaturesKey(release.OpsAccount, "titogo"), []byte(`{"schema":9}`), ""); err != nil { |
| 220 | t.Fatal(err) |
| 221 | } |
| 222 | code, _, out := rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo"), member, "") |
| 223 | if code != http.StatusOK { |
| 224 | t.Fatalf("domains list with a bad features record = %d %s, want 200", code, out) |
| 225 | } |
| 226 | if !strings.Contains(out, `"features":null`) { |
| 227 | t.Fatalf("block = %s, want \"features\":null for an unreadable record", out) |
| 228 | } |
| 229 | // And the dedicated read says so out loud rather than lying with {}. |
| 230 | status, body := getRetention(t, featuresURL(fx, "bab", "titogo"), member) |
| 231 | if status != http.StatusBadGateway { |
| 232 | t.Fatalf("features get with a bad record = %d %s, want 502", status, body) |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | // Roles: the app's own instance credential READS (it is the consumer) and |
| 237 | // may never WRITE — a switch the app could flip on itself is a root env |
| 238 | // flag by another name, the exact thing #347 exists to retire. Publish reads |
| 239 | // too; only admin sets. |
| 240 | func TestAPIFeaturesRoles(t *testing.T) { |
| 241 | fx, _ := instanceFixture(t, "code-feat-roles") |
| 242 | instance := serviceToken(t, fx.st, OpsAccountID, "router", "titogo", svcrole.Instance) |
| 243 | publish := serviceToken(t, fx.st, OpsAccountID, "ci", "titogo", svcrole.Publish) |
| 244 | operate := serviceToken(t, fx.st, OpsAccountID, "ops", "titogo", svcrole.Operate) |
| 245 | admin := serviceToken(t, fx.st, OpsAccountID, "root", "titogo", svcrole.Admin) |
| 246 | |
| 247 | for name, tok := range map[string]string{"instance": instance, "publish": publish} { |
| 248 | if status, out := getRetention(t, featuresURL(fx, "bab", "titogo"), tok); status != http.StatusOK { |
| 249 | t.Errorf("%s GET = %d %s, want 200", name, status, out) |
| 250 | } |
| 251 | } |
| 252 | if status, _ := getRetention(t, featuresURL(fx, "bab", "titogo"), operate); status != http.StatusForbidden { |
| 253 | t.Errorf("operate GET = %d, want 403", status) |
| 254 | } |
| 255 | for name, tok := range map[string]string{"instance": instance, "publish": publish, "operate": operate} { |
| 256 | if status, out := postBody(t, featuresURL(fx, "bab", "titogo"), tok, `{"set": {"sandbox": "on"}}`); status != http.StatusForbidden { |
| 257 | t.Errorf("%s POST = %d %s, want 403", name, status, out) |
| 258 | } |
| 259 | } |
| 260 | if status, out := postBody(t, featuresURL(fx, "bab", "titogo"), admin, `{"set": {"sandbox": "on"}}`); status != http.StatusOK { |
| 261 | t.Errorf("admin POST = %d %s, want 200", status, out) |
| 262 | } |
| 263 | } |
| 264 | |
| 265 | // The console surface (#347): the app Settings page shows the flags the |
| 266 | // platform serves, read-only, with the last change beside them — so a |
| 267 | // member can see what an operator set without a terminal. Writes stay on |
| 268 | // the CLI door, where they are role-gated and audited. |
| 269 | func TestSettingsPageShowsFeatureFlags(t *testing.T) { |
| 270 | fx := newConsoleFixture(t) |
| 271 | c := signInPaul(t, fx) |
| 272 | freshApp(t, fx, "titogo") |
| 273 | when := time.Date(2026, 8, 28, 9, 30, 0, 0, time.UTC) |
| 274 | if _, _, err := release.UpdateFeatures(context.Background(), fx.rel, release.OpsAccount, "titogo", |
| 275 | map[string]string{"sandbox": "on", "beta-ui": "v2"}, nil, "alice@keymail.dev", when); err != nil { |
| 276 | t.Fatal(err) |
| 277 | } |
| 278 | |
| 279 | status, _, body := get(t, c, fx.u("/apps/titogo/settings/features")) |
| 280 | if status != http.StatusOK { |
| 281 | t.Fatalf("settings page = %d:\n%s", status, body) |
| 282 | } |
| 283 | for _, want := range []string{ |
| 284 | "<h2>Feature flags</h2>", |
| 285 | "<code>sandbox</code>", "<code>on</code>", |
| 286 | "<code>beta-ui</code>", "<code>v2</code>", |
| 287 | "alice@keymail.dev", |
| 288 | "carlos features set --app titogo", |
| 289 | } { |
| 290 | if !strings.Contains(body, want) { |
| 291 | t.Fatalf("the feature flags card is missing %q:\n%s", want, body) |
| 292 | } |
| 293 | } |
| 294 | // Sorted by key: beta-ui before sandbox, whatever order the map ranges. |
| 295 | if strings.Index(body, "<code>beta-ui</code>") > strings.Index(body, "<code>sandbox</code>") { |
| 296 | t.Fatalf("flags are not key-sorted:\n%s", body) |
| 297 | } |
| 298 | |
| 299 | // An app with none set says so, and still tells the member how one |
| 300 | // gets set. |
| 301 | freshApp(t, fx, "sprout") |
| 302 | _, _, body = get(t, c, fx.u("/apps/sprout/settings/features")) |
| 303 | if !strings.Contains(body, "No feature flags") || !strings.Contains(body, "carlos features set --app sprout") { |
| 304 | t.Fatalf("an app with no flags is not told so:\n%s", body) |
| 305 | } |
| 306 | } |
| 307 | |