| 1 | package 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 | |
| 14 | import ( |
| 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 | |
| 27 | func 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. |
| 38 | func 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). |
| 122 | func 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. |
| 178 | func 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. |
| 262 | func 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 | |