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
3// The member-facing instances-manifest door (issue #66): the one write that
4// used to need a box shell or bucket credentials between "promoted" and
5// "reachable". The properties that matter, in the order they'd hurt if
6// lost: the manifest's domain is pinned server-side to the account's own
7// platform subdomain (nothing the request carries can name another
8// account's — or anyone's custom — domain), an operator-written manifest
9// under a different domain is refused rather than overwritten, the refusals
10// before membership is proven are the family's byte-identical 404, an app
11// token cannot enable anything, and pool/regions — operator policy — ride
12// through updates untouched.
13
14import (
15 "context"
16 "encoding/json"
17 "net/http"
18 "path/filepath"
19 "strings"
20 "testing"
21 "time"
22
23 "github.com/carlosframework/platform/internal/provision"
24 "github.com/carlosframework/platform/internal/registry"
25)
26
27func enableURL(fx *consoleFixture, acct, app string) string {
28 return fx.ts.URL + "/api/cli/apps/" + acct + "/" + app + "/instances-manifest"
29}
30
31// TestAPIInstanceEnableChain is issue #66's done-criteria as a test, on a
32// NON-ops account (the seam test's discipline — ops's sqid is hardcoded
33// known elsewhere, so an ops-only test would pass with the sqid agreement
34// broken): a member with a login credential and no bucket access enables
35// instances, creates the app's first record at its own platform host, and
36// the agent's reconcile pass — joined to the console by nothing but the
37// bucket — materializes the route that brings the host live.
38func TestAPIInstanceEnableChain(t *testing.T) {
39 fx := newConsoleFixture(t)
40 fx.srv.cfg.AppsDomain = "oncarlos.test"
41 token := cliToken(t, fx, "alice@keymail.dev", "code-inst-enable")
42 now := time.Now()
43 const acctID int64 = 4242
44 sqid := accountSqid(acctID)
45 if err := fx.st.CreateAccount(acctID, sqid, "Amadan", "alice@keymail.dev", now); err != nil {
46 t.Fatal(err)
47 }
48 if _, err := fx.st.CreateAppIn(acctID, "hub", "alice@keymail.dev", now); err != nil {
49 t.Fatal(err)
50 }
51 if err := WriteAccountManifest(context.Background(), fx.rel, acctID, sqid, "Amadan", now); err != nil {
52 t.Fatal(err)
53 }
54
55 wantDomain := sqid + ".oncarlos.test"
56 status, _, out := rawReq(t, http.MethodPut, enableURL(fx, sqid, "hub"), token, `{"channel":"beta"}`)
57 if status != http.StatusOK {
58 t.Fatalf("enable = %d %s", status, out)
59 }
60 var res struct {
61 App string `json:"app"`
62 Domain string `json:"domain"`
63 Channel string `json:"channel"`
64 }
65 if err := json.Unmarshal([]byte(out), &res); err != nil {
66 t.Fatal(err)
67 }
68 if res.App != "hub" || res.Domain != wantDomain || res.Channel != "beta" {
69 t.Fatalf("enable response = %+v, want hub under %s on beta", res, wantDomain)
70 }
71 m, err := provision.GetManifest(context.Background(), fx.rel, sqid, "hub")
72 if err != nil {
73 t.Fatalf("manifest after enable: %v", err)
74 }
75 if m.Domain != wantDomain || m.App != "hub" || m.Channel != "beta" || m.Pool != "" || len(m.Regions) != 0 {
76 t.Fatalf("manifest = %+v, want domain %s, channel beta, empty pool/regions", m, wantDomain)
77 }
78
79 // The app's own platform host is one label under the pinned domain —
80 // exactly what the existing create door now accepts.
81 host := "hub." + wantDomain
82 status, _, out = rawReq(t, http.MethodPost, instancesURL(fx, sqid, "hub"), token, `{"host":"`+host+`"}`)
83 if status != http.StatusCreated {
84 t.Fatalf("create = %d %s", status, out)
85 }
86
87 // The agent's half: an unscoped box (no region, no pools — the
88 // single-box deployment shape) materializes the record as the route.
89 dataDir := t.TempDir()
90 reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
91 if err != nil {
92 t.Fatal(err)
93 }
94 t.Cleanup(func() { reg.Close() })
95 if err := provision.Reconcile(context.Background(), fx.rel, reg,
96 provision.ReconcileConfig{DataDir: dataDir}); err != nil {
97 t.Fatalf("reconcile: %v", err)
98 }
99 got, err := reg.Get(host)
100 if err != nil {
101 t.Fatal(err)
102 }
103 if got == nil {
104 t.Fatalf("the enabled app's record never materialized as a route")
105 }
106 want := registry.Route{
107 Host: host, Slug: "hub", Kind: "instance",
108 Socket: provision.DefaultSocket(host), DBPath: provision.DefaultDBPath(dataDir, host),
109 App: "hub", Account: sqid, Channel: "beta",
110 Hibernate: true, Provisioned: true, Status: "ready",
111 CreatedAt: got.CreatedAt,
112 }
113 if *got != want {
114 t.Fatalf("minted route =\n %+v\nwant\n %+v", *got, want)
115 }
116}
117
118// TestAPIInstanceEnableUpdatePreserves: an update through this door touches
119// only what the request names. Pool and regions are operator policy the
120// door never speaks for; channel and seed follow the absent-vs-explicit
121// split (absent preserves, explicit replaces, seed's [] clears).
122func TestAPIInstanceEnableUpdatePreserves(t *testing.T) {
123 fx, token := instanceFixture(t, "code-inst-enable-upd")
124 fx.srv.cfg.AppsDomain = "oncarlos.test"
125 domain := fx.sqid + ".oncarlos.test"
126 // An operator already wrote a platform-domain manifest with policy in
127 // it: same domain this door would pin, so updates are legal — but its
128 // pool and regions must ride through untouched.
129 enableInstances(t, fx, "titogo", provision.Manifest{
130 Schema: 1, App: "titogo", Domain: domain,
131 Pool: "p1", Channel: "stable", Regions: []string{"ie"}, Seed: []string{"seed", "arg"},
132 })
133
134 u := enableURL(fx, fx.sqid, "titogo")
135 if status, _, out := rawReq(t, http.MethodPut, u, token, `{"channel":"beta"}`); status != http.StatusOK {
136 t.Fatalf("update channel = %d %s", status, out)
137 }
138 m, err := provision.GetManifest(context.Background(), fx.rel, fx.sqid, "titogo")
139 if err != nil {
140 t.Fatal(err)
141 }
142 if m.Channel != "beta" || m.Pool != "p1" || len(m.Regions) != 1 || m.Regions[0] != "ie" ||
143 len(m.Seed) != 2 {
144 t.Fatalf("after channel update, manifest = %+v: want beta with pool/regions/seed preserved", m)
145 }
146
147 if status, _, out := rawReq(t, http.MethodPut, u, token, `{"seed":[]}`); status != http.StatusOK {
148 t.Fatalf("clear seed = %d %s", status, out)
149 }
150 m, err = provision.GetManifest(context.Background(), fx.rel, fx.sqid, "titogo")
151 if err != nil {
152 t.Fatal(err)
153 }
154 if len(m.Seed) != 0 || m.Channel != "beta" || m.Pool != "p1" {
155 t.Fatalf("after seed clear, manifest = %+v: want seed gone, everything else standing", m)
156 }
157
158 if status, _, out := rawReq(t, http.MethodPut, u, token, `{}`); status != http.StatusOK {
159 t.Fatalf("empty update = %d %s", status, out)
160 }
161 m, err = provision.GetManifest(context.Background(), fx.rel, fx.sqid, "titogo")
162 if err != nil {
163 t.Fatal(err)
164 }
165 if m.Channel != "beta" || m.Pool != "p1" || len(m.Regions) != 1 {
166 t.Fatalf("after empty update, manifest = %+v: want everything preserved", m)
167 }
168}
169
170// TestAPIInstanceCreateSiblingApexRefused: the account's platform subdomain
171// is a namespace every app's apex shares, so a manifest pinned there
172// (exactly what the enable door writes) makes sibling apexes valid host
173// SHAPES for the wrong app. A record at a sibling's apex would mint a real
174// route the domains pass then defers to forever — one app serving another's
175// public URL — so the create door refuses a label that names another
176// claimed app in the account, for user and app tokens both. Unclaimed
177// labels and the app's own apex stay open.
178func TestAPIInstanceCreateSiblingApexRefused(t *testing.T) {
179 fx, member := instanceFixture(t, "code-inst-sibling")
180 fx.srv.cfg.AppsDomain = "oncarlos.test"
181 now := time.Now()
182 if _, err := fx.st.CreateAppIn(OpsAccountID, "marketing", "paul@keymail.dev", now); err != nil {
183 t.Fatal(err)
184 }
185 if status, _, out := rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), member, `{}`); status != http.StatusOK {
186 t.Fatalf("enable = %d %s", status, out)
187 }
188 domain := fx.sqid + ".oncarlos.test"
189
190 status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member,
191 `{"host":"marketing.`+domain+`"}`)
192 if status != http.StatusConflict {
193 t.Fatalf("sibling apex create = %d %s, want 409", status, out)
194 }
195 if !strings.Contains(out, "marketing") || !strings.Contains(out, "platform apex") {
196 t.Fatalf("sibling refusal body = %s, want it to name the app and the apex", out)
197 }
198
199 // The app token meets the same wall: it authorizes titogo's records,
200 // and a sibling's apex is not one of them.
201 appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", now)
202 if err != nil {
203 t.Fatal(err)
204 }
205 if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), appTok,
206 `{"host":"marketing.`+domain+`"}`); status != http.StatusConflict {
207 t.Fatalf("app-token sibling apex create = %d %s, want 409", status, out)
208 }
209
210 // A sibling in the TRASH still owns its apex: the name is reserved until
211 // purge, and an apex taken while it is trashed would be kept across a
212 // restore (discussion 153). Only a purge frees it.
213 if err := fx.st.TrashAppRow(OpsAccountID, "marketing", now); err != nil {
214 t.Fatal(err)
215 }
216 status, _, out = rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), appTok, `{"host":"marketing.`+domain+`"}`)
217 if status != http.StatusConflict || !strings.Contains(out, "trash") {
218 t.Fatalf("trashed sibling apex create = %d %s, want 409 naming the trash", status, out)
219 }
220 if err := fx.st.RestoreAppRow(OpsAccountID, "marketing"); err != nil {
221 t.Fatal(err)
222 }
223 if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member,
224 `{"host":"marketing.`+domain+`"}`); status != http.StatusConflict {
225 t.Fatalf("restored sibling apex create = %d %s, want 409", status, out)
226 }
227 if err := fx.st.TrashAppRow(OpsAccountID, "marketing", now); err != nil {
228 t.Fatal(err)
229 }
230 if err := fx.st.PurgeAppRow(OpsAccountID, "marketing"); err != nil {
231 t.Fatal(err)
232 }
233 if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member,
234 `{"host":"marketing.`+domain+`"}`); status != http.StatusCreated {
235 t.Fatalf("a purged sibling's label create = %d %s, want 201 — a purge frees the apex", status, out)
236 }
237 // ...and having taken it, titogo holds it: an app created under that
238 // name afterwards would never get its own URL, so the name is not free
239 // (apex.go) until titogo's instance there is gone.
240 if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new", map[string]string{"name": "marketing"}, member); status != http.StatusConflict {
241 t.Fatalf("re-creating marketing under titogo's instance = %d %v, want 409", status, out)
242 }
243
244 // The app's own apex and an unclaimed label are exactly what the door
245 // is for.
246 if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member,
247 `{"host":"titogo.`+domain+`"}`); status != http.StatusCreated {
248 t.Fatalf("own apex create = %d %s, want 201", status, out)
249 }
250 if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member,
251 `{"host":"acme.`+domain+`"}`); status != http.StatusCreated {
252 t.Fatalf("unclaimed label create = %d %s, want 201", status, out)
253 }
254}
255
256// TestAPIInstanceEnableRefusals: every way of not being allowed to enable,
257// and the order the answers keep secrets in. Before membership is proven the
258// answer is the family's byte-identical 404; a service credential meets the
259// closed door's 403 (enabling is a human act); only after the gate do causes get names —
260// including the one refusal that guards somebody else's standing policy,
261// the foreign-domain 409.
262func TestAPIInstanceEnableRefusals(t *testing.T) {
263 fx, member := instanceFixture(t, "code-inst-enable-ref")
264 fx.srv.cfg.AppsDomain = "oncarlos.test"
265 want := canonicalNotFound(t)
266 eve := cliToken(t, fx, "eve@keymail.dev", "code-inst-enable-eve")
267 appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", time.Now())
268 if err != nil {
269 t.Fatal(err)
270 }
271
272 for _, c := range []struct {
273 what, acct, app, token string
274 }{
275 {"non-member", "bab", "titogo", eve},
276 {"unknown account", "zzzz", "titogo", member},
277 {"unclaimed app", "bab", "ghost-app", member},
278 } {
279 status, h, out := rawReq(t, http.MethodPut, enableURL(fx, c.acct, c.app), c.token, `{}`)
280 assertNotFound(t, "enable: "+c.what, want, status, h, out)
281 }
282
283 // An app token cannot enable. The containment is unchanged and always
284 // was — no app token has ever reached this handler — but the ANSWER
285 // moved from 401 to 403 when the door was registered with the zero Set
286 // (service-principals ruling C25). Enabling writes the manifest that
287 // bounds what the app may ever be brought up as, so it takes the same
288 // escalation cut the mint door takes, and the refusal now comes from
289 // cliRoute before the handler rather than from cliUser inside it.
290 if status, _, out := rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), appTok, `{}`); status != http.StatusForbidden {
291 t.Fatalf("app token enable = %d %s, want 403", status, out)
292 }
293
294 // Cheap validation refusals, after the gate.
295 for _, c := range []struct {
296 what, body string
297 }{
298 {"bad channel", `{"channel":"NOT a channel"}`},
299 {"too many seed args", `{"seed":[` + strings.Repeat(`"a",`, 16) + `"a"]}`},
300 {"empty seed arg", `{"seed":[""]}`},
301 {"oversized seed arg", `{"seed":["` + strings.Repeat("a", 257) + `"]}`},
302 } {
303 if status, _, out := rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), member, c.body); status != http.StatusBadRequest {
304 t.Fatalf("enable %s = %d %s, want 400", c.what, status, out)
305 }
306 }
307
308 // A standing manifest under any other domain — an operator's custom
309 // domain — is never overwritten from here.
310 enableInstances(t, fx, "titogo", titoManifest())
311 status, _, out := rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), member, `{"channel":"beta"}`)
312 if status != http.StatusConflict {
313 t.Fatalf("enable over custom domain = %d %s, want 409", status, out)
314 }
315 if !strings.Contains(out, "go.tito.io") || !strings.Contains(out, "operator act") {
316 t.Fatalf("conflict body = %s, want it to name the standing domain and the operator act", out)
317 }
318 if m, err := provision.GetManifest(context.Background(), fx.rel, "bab", "titogo"); err != nil || m.Domain != "go.tito.io" {
319 t.Fatalf("manifest after refused enable = %+v %v, want go.tito.io untouched", m, err)
320 }
321
322 // No PLATFORM_APPS_DOMAIN on this console: the member-facing door does
323 // not exist, and says which operator act does.
324 fx.srv.cfg.AppsDomain = ""
325 status, _, out = rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), member, `{}`)
326 if status != http.StatusServiceUnavailable {
327 t.Fatalf("enable with no apps domain = %d %s, want 503", status, out)
328 }
329 if !strings.Contains(out, "PLATFORM_APPS_DOMAIN") {
330 t.Fatalf("no-apps-domain body = %s, want it to name the missing configuration", out)
331 }
332}
333