carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
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
15func 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.
20type 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
29func 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
38func 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
97func 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
119func 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
134func 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.
156func 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.
217func 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.
240func 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.
269func 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