package console // The member-facing instances-manifest door (issue #66): the one write that // used to need a box shell or bucket credentials between "promoted" and // "reachable". The properties that matter, in the order they'd hurt if // lost: the manifest's domain is pinned server-side to the account's own // platform subdomain (nothing the request carries can name another // account's — or anyone's custom — domain), an operator-written manifest // under a different domain is refused rather than overwritten, the refusals // before membership is proven are the family's byte-identical 404, an app // token cannot enable anything, and pool/regions — operator policy — ride // through updates untouched. import ( "context" "encoding/json" "net/http" "path/filepath" "strings" "testing" "time" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/registry" ) func enableURL(fx *consoleFixture, acct, app string) string { return fx.ts.URL + "/api/cli/apps/" + acct + "/" + app + "/instances-manifest" } // TestAPIInstanceEnableChain is issue #66's done-criteria as a test, on a // NON-ops account (the seam test's discipline — ops's sqid is hardcoded // known elsewhere, so an ops-only test would pass with the sqid agreement // broken): a member with a login credential and no bucket access enables // instances, creates the app's first record at its own platform host, and // the agent's reconcile pass — joined to the console by nothing but the // bucket — materializes the route that brings the host live. func TestAPIInstanceEnableChain(t *testing.T) { fx := newConsoleFixture(t) fx.srv.cfg.AppsDomain = "oncarlos.test" token := cliToken(t, fx, "alice@keymail.dev", "code-inst-enable") now := time.Now() const acctID int64 = 4242 sqid := accountSqid(acctID) if err := fx.st.CreateAccount(acctID, sqid, "Amadan", "alice@keymail.dev", now); err != nil { t.Fatal(err) } if _, err := fx.st.CreateAppIn(acctID, "hub", "alice@keymail.dev", now); err != nil { t.Fatal(err) } if err := WriteAccountManifest(context.Background(), fx.rel, acctID, sqid, "Amadan", now); err != nil { t.Fatal(err) } wantDomain := sqid + ".oncarlos.test" status, _, out := rawReq(t, http.MethodPut, enableURL(fx, sqid, "hub"), token, `{"channel":"beta"}`) if status != http.StatusOK { t.Fatalf("enable = %d %s", status, out) } var res struct { App string `json:"app"` Domain string `json:"domain"` Channel string `json:"channel"` } if err := json.Unmarshal([]byte(out), &res); err != nil { t.Fatal(err) } if res.App != "hub" || res.Domain != wantDomain || res.Channel != "beta" { t.Fatalf("enable response = %+v, want hub under %s on beta", res, wantDomain) } m, err := provision.GetManifest(context.Background(), fx.rel, sqid, "hub") if err != nil { t.Fatalf("manifest after enable: %v", err) } if m.Domain != wantDomain || m.App != "hub" || m.Channel != "beta" || m.Pool != "" || len(m.Regions) != 0 { t.Fatalf("manifest = %+v, want domain %s, channel beta, empty pool/regions", m, wantDomain) } // The app's own platform host is one label under the pinned domain — // exactly what the existing create door now accepts. host := "hub." + wantDomain status, _, out = rawReq(t, http.MethodPost, instancesURL(fx, sqid, "hub"), token, `{"host":"`+host+`"}`) if status != http.StatusCreated { t.Fatalf("create = %d %s", status, out) } // The agent's half: an unscoped box (no region, no pools — the // single-box deployment shape) materializes the record as the route. dataDir := t.TempDir() reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { reg.Close() }) if err := provision.Reconcile(context.Background(), fx.rel, reg, provision.ReconcileConfig{DataDir: dataDir}); err != nil { t.Fatalf("reconcile: %v", err) } got, err := reg.Get(host) if err != nil { t.Fatal(err) } if got == nil { t.Fatalf("the enabled app's record never materialized as a route") } want := registry.Route{ Host: host, Slug: "hub", Kind: "instance", Socket: provision.DefaultSocket(host), DBPath: provision.DefaultDBPath(dataDir, host), App: "hub", Account: sqid, Channel: "beta", Hibernate: true, Provisioned: true, Status: "ready", CreatedAt: got.CreatedAt, } if *got != want { t.Fatalf("minted route =\n %+v\nwant\n %+v", *got, want) } } // TestAPIInstanceEnableUpdatePreserves: an update through this door touches // only what the request names. Pool and regions are operator policy the // door never speaks for; channel and seed follow the absent-vs-explicit // split (absent preserves, explicit replaces, seed's [] clears). func TestAPIInstanceEnableUpdatePreserves(t *testing.T) { fx, token := instanceFixture(t, "code-inst-enable-upd") fx.srv.cfg.AppsDomain = "oncarlos.test" domain := fx.sqid + ".oncarlos.test" // An operator already wrote a platform-domain manifest with policy in // it: same domain this door would pin, so updates are legal — but its // pool and regions must ride through untouched. enableInstances(t, fx, "titogo", provision.Manifest{ Schema: 1, App: "titogo", Domain: domain, Pool: "p1", Channel: "stable", Regions: []string{"ie"}, Seed: []string{"seed", "arg"}, }) u := enableURL(fx, fx.sqid, "titogo") if status, _, out := rawReq(t, http.MethodPut, u, token, `{"channel":"beta"}`); status != http.StatusOK { t.Fatalf("update channel = %d %s", status, out) } m, err := provision.GetManifest(context.Background(), fx.rel, fx.sqid, "titogo") if err != nil { t.Fatal(err) } if m.Channel != "beta" || m.Pool != "p1" || len(m.Regions) != 1 || m.Regions[0] != "ie" || len(m.Seed) != 2 { t.Fatalf("after channel update, manifest = %+v: want beta with pool/regions/seed preserved", m) } if status, _, out := rawReq(t, http.MethodPut, u, token, `{"seed":[]}`); status != http.StatusOK { t.Fatalf("clear seed = %d %s", status, out) } m, err = provision.GetManifest(context.Background(), fx.rel, fx.sqid, "titogo") if err != nil { t.Fatal(err) } if len(m.Seed) != 0 || m.Channel != "beta" || m.Pool != "p1" { t.Fatalf("after seed clear, manifest = %+v: want seed gone, everything else standing", m) } if status, _, out := rawReq(t, http.MethodPut, u, token, `{}`); status != http.StatusOK { t.Fatalf("empty update = %d %s", status, out) } m, err = provision.GetManifest(context.Background(), fx.rel, fx.sqid, "titogo") if err != nil { t.Fatal(err) } if m.Channel != "beta" || m.Pool != "p1" || len(m.Regions) != 1 { t.Fatalf("after empty update, manifest = %+v: want everything preserved", m) } } // TestAPIInstanceCreateSiblingApexRefused: the account's platform subdomain // is a namespace every app's apex shares, so a manifest pinned there // (exactly what the enable door writes) makes sibling apexes valid host // SHAPES for the wrong app. A record at a sibling's apex would mint a real // route the domains pass then defers to forever — one app serving another's // public URL — so the create door refuses a label that names another // claimed app in the account, for user and app tokens both. Unclaimed // labels and the app's own apex stay open. func TestAPIInstanceCreateSiblingApexRefused(t *testing.T) { fx, member := instanceFixture(t, "code-inst-sibling") fx.srv.cfg.AppsDomain = "oncarlos.test" now := time.Now() if _, err := fx.st.CreateAppIn(OpsAccountID, "marketing", "paul@keymail.dev", now); err != nil { t.Fatal(err) } if status, _, out := rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), member, `{}`); status != http.StatusOK { t.Fatalf("enable = %d %s", status, out) } domain := fx.sqid + ".oncarlos.test" status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member, `{"host":"marketing.`+domain+`"}`) if status != http.StatusConflict { t.Fatalf("sibling apex create = %d %s, want 409", status, out) } if !strings.Contains(out, "marketing") || !strings.Contains(out, "platform apex") { t.Fatalf("sibling refusal body = %s, want it to name the app and the apex", out) } // The app token meets the same wall: it authorizes titogo's records, // and a sibling's apex is not one of them. appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", now) if err != nil { t.Fatal(err) } if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), appTok, `{"host":"marketing.`+domain+`"}`); status != http.StatusConflict { t.Fatalf("app-token sibling apex create = %d %s, want 409", status, out) } // A sibling in the TRASH still owns its apex: the name is reserved until // purge, and an apex taken while it is trashed would be kept across a // restore (discussion 153). Only a purge frees it. if err := fx.st.TrashAppRow(OpsAccountID, "marketing", now); err != nil { t.Fatal(err) } status, _, out = rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), appTok, `{"host":"marketing.`+domain+`"}`) if status != http.StatusConflict || !strings.Contains(out, "trash") { t.Fatalf("trashed sibling apex create = %d %s, want 409 naming the trash", status, out) } if err := fx.st.RestoreAppRow(OpsAccountID, "marketing"); err != nil { t.Fatal(err) } if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member, `{"host":"marketing.`+domain+`"}`); status != http.StatusConflict { t.Fatalf("restored sibling apex create = %d %s, want 409", status, out) } if err := fx.st.TrashAppRow(OpsAccountID, "marketing", now); err != nil { t.Fatal(err) } if err := fx.st.PurgeAppRow(OpsAccountID, "marketing"); err != nil { t.Fatal(err) } if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member, `{"host":"marketing.`+domain+`"}`); status != http.StatusCreated { t.Fatalf("a purged sibling's label create = %d %s, want 201 — a purge frees the apex", status, out) } // ...and having taken it, titogo holds it: an app created under that // name afterwards would never get its own URL, so the name is not free // (apex.go) until titogo's instance there is gone. if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new", map[string]string{"name": "marketing"}, member); status != http.StatusConflict { t.Fatalf("re-creating marketing under titogo's instance = %d %v, want 409", status, out) } // The app's own apex and an unclaimed label are exactly what the door // is for. if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member, `{"host":"titogo.`+domain+`"}`); status != http.StatusCreated { t.Fatalf("own apex create = %d %s, want 201", status, out) } if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member, `{"host":"acme.`+domain+`"}`); status != http.StatusCreated { t.Fatalf("unclaimed label create = %d %s, want 201", status, out) } } // TestAPIInstanceEnableRefusals: every way of not being allowed to enable, // and the order the answers keep secrets in. Before membership is proven the // answer is the family's byte-identical 404; a service credential meets the // closed door's 403 (enabling is a human act); only after the gate do causes get names — // including the one refusal that guards somebody else's standing policy, // the foreign-domain 409. func TestAPIInstanceEnableRefusals(t *testing.T) { fx, member := instanceFixture(t, "code-inst-enable-ref") fx.srv.cfg.AppsDomain = "oncarlos.test" want := canonicalNotFound(t) eve := cliToken(t, fx, "eve@keymail.dev", "code-inst-enable-eve") appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", time.Now()) if err != nil { t.Fatal(err) } for _, c := range []struct { what, acct, app, token string }{ {"non-member", "bab", "titogo", eve}, {"unknown account", "zzzz", "titogo", member}, {"unclaimed app", "bab", "ghost-app", member}, } { status, h, out := rawReq(t, http.MethodPut, enableURL(fx, c.acct, c.app), c.token, `{}`) assertNotFound(t, "enable: "+c.what, want, status, h, out) } // An app token cannot enable. The containment is unchanged and always // was — no app token has ever reached this handler — but the ANSWER // moved from 401 to 403 when the door was registered with the zero Set // (service-principals ruling C25). Enabling writes the manifest that // bounds what the app may ever be brought up as, so it takes the same // escalation cut the mint door takes, and the refusal now comes from // cliRoute before the handler rather than from cliUser inside it. if status, _, out := rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), appTok, `{}`); status != http.StatusForbidden { t.Fatalf("app token enable = %d %s, want 403", status, out) } // Cheap validation refusals, after the gate. for _, c := range []struct { what, body string }{ {"bad channel", `{"channel":"NOT a channel"}`}, {"too many seed args", `{"seed":[` + strings.Repeat(`"a",`, 16) + `"a"]}`}, {"empty seed arg", `{"seed":[""]}`}, {"oversized seed arg", `{"seed":["` + strings.Repeat("a", 257) + `"]}`}, } { if status, _, out := rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), member, c.body); status != http.StatusBadRequest { t.Fatalf("enable %s = %d %s, want 400", c.what, status, out) } } // A standing manifest under any other domain — an operator's custom // domain — is never overwritten from here. enableInstances(t, fx, "titogo", titoManifest()) status, _, out := rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), member, `{"channel":"beta"}`) if status != http.StatusConflict { t.Fatalf("enable over custom domain = %d %s, want 409", status, out) } if !strings.Contains(out, "go.tito.io") || !strings.Contains(out, "operator act") { t.Fatalf("conflict body = %s, want it to name the standing domain and the operator act", out) } if m, err := provision.GetManifest(context.Background(), fx.rel, "bab", "titogo"); err != nil || m.Domain != "go.tito.io" { t.Fatalf("manifest after refused enable = %+v %v, want go.tito.io untouched", m, err) } // No PLATFORM_APPS_DOMAIN on this console: the member-facing door does // not exist, and says which operator act does. fx.srv.cfg.AppsDomain = "" status, _, out = rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), member, `{}`) if status != http.StatusServiceUnavailable { t.Fatalf("enable with no apps domain = %d %s, want 503", status, out) } if !strings.Contains(out, "PLATFORM_APPS_DOMAIN") { t.Fatalf("no-apps-domain body = %s, want it to name the missing configuration", out) } }