| index 163d49993..be420b3b3 100644 |
| --- a/internal/console/controlplane_test.go |
| +++ b/internal/console/controlplane_test.go |
| @@ -14,6 +14,7 @@ import ( |
| "time" |
| |
| "github.com/carlosframework/platform/internal/objstore" |
| + "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/svcrole" |
| ) |
| @@ -864,3 +865,209 @@ func TestControlPlaneShipRefusalNamesTheMintTrap(t *testing.T) { |
| t.Fatalf("minted ship of an ordinary app = %d %v, want 201", status, out) |
| } |
| } |
| + |
| +// --- The instance doors ------------------------------------------------------ |
| +// |
| +// The eighth door, found mid-rollout. `carlos deploy` resolves hosts through |
| +// instance records, so the console needs one — but a record is an AUTHORITY |
| +// TRANSFER (AGENTS.md): once it exists and the route is Provisioned, the |
| +// provisioning reconciler owns the row, and its removal phase retires the |
| +// route the moment the record disappears. So a single DELETE on this family |
| +// takes the control plane off the air, and PATCH repoints it. |
| +// |
| +// The split is the one the flag already draws everywhere else: destruction is |
| +// refused outright; the WRITES a person legitimately performs stay open to a |
| +// person and closed to a CI credential. |
| + |
| +// cpInstanceFixture is controlPlaneFixture with the instance feature enabled |
| +// on the flagged `console` app and on an ordinary `widget` beside it, so |
| +// every test below can assert the refusal AND that a tenant's identical call |
| +// is untouched by it. |
| +// |
| +// Custom domains deliberately, not the platform apex: the sibling-apex rail |
| +// in handleAPIInstanceCreate only fires inside <sqid>.<AppsDomain>, and these |
| +// tests are about the flag, not about that rail. |
| +func cpInstanceFixture(t *testing.T, label string) (*consoleFixture, string) { |
| + t.Helper() |
| + fx, token := controlPlaneFixture(t, label) |
| + enableInstances(t, fx, "console", provision.Manifest{Schema: 1, App: "console", |
| + Domain: "cp.tito.io", Pool: "tito", Channel: "stable", Regions: []string{"ie"}}) |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + enableInstances(t, fx, "widget", provision.Manifest{Schema: 1, App: "widget", |
| + Domain: "widget.tito.io", Pool: "tito", Channel: "stable", Regions: []string{"ie"}}) |
| + return fx, token |
| +} |
| + |
| +// TestControlPlaneAppRefusesInstanceDelete is the whole reason this slice |
| +// exists. Deleting the console's instance record is not an edit to a bucket |
| +// object — it is the instruction the reconciler's removal phase acts on, and |
| +// the act it performs is retiring the control plane's route. Refused |
| +// OUTRIGHT, for a person as much as for a service: the same class as trash, |
| +// purge and store-destroy, and the same way out (clear the flag first). |
| +func TestControlPlaneAppRefusesInstanceDelete(t *testing.T) { |
| + fx, token := cpInstanceFixture(t, "code-cp-inst-del") |
| + u := instancesURL(fx, "bab", "console") |
| + |
| + // The record exists first — created by the person the next test proves |
| + // may create it — so the refusal below is refusing a delete that would |
| + // otherwise have succeeded. |
| + if status, _, out := rawReq(t, http.MethodPost, u, token, |
| + `{"host":"box1.cp.tito.io","region":"ie"}`); status != http.StatusCreated { |
| + t.Fatalf("create = %d %s", status, out) |
| + } |
| + |
| + status, _, out := rawReq(t, http.MethodDelete, u+"/box1.cp.tito.io", token, "") |
| + if status != http.StatusForbidden { |
| + t.Fatalf("a PERSON deleting the control plane's instance record = %d %s, want 403", status, out) |
| + } |
| + if !strings.Contains(out, "control plane") { |
| + t.Fatalf("refusal %q does not name the flag the operator has to clear", out) |
| + } |
| + if !strings.Contains(out, "/a/bab/apps/console/settings") { |
| + t.Fatalf("refusal %q does not point at the settings card that can clear the flag", out) |
| + } |
| + // The record survived. This is the assertion the door exists for: the |
| + // reconciler never sees the record go, so the route is never retired. |
| + if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "console", "box1.cp.tito.io"); err != nil { |
| + t.Fatalf("the control plane's instance record did not survive a refused delete: %v", err) |
| + } |
| + |
| + // A service credential holding the role this door grants meets the same |
| + // wall — it is refused for what the app IS, not for what the caller is. |
| + svc := serviceToken(t, fx.st, OpsAccountID, "ci", "console", svcrole.Instance) |
| + if status, _, out := rawReq(t, http.MethodDelete, u+"/box1.cp.tito.io", svc, ""); status != http.StatusForbidden { |
| + t.Fatalf("a SERVICE deleting the control plane's instance record = %d %s, want 403", status, out) |
| + } |
| + |
| + // A traversal host on the flagged app is refused by the flag, not by the |
| + // shape check: nothing below the guard runs for a control-plane app. |
| + if status, _, out := rawReq(t, http.MethodDelete, u+"/..%2f..%2finstances.json", token, ""); status != http.StatusForbidden { |
| + t.Fatalf("traversal delete on a flagged app = %d %s, want the flag's 403", status, out) |
| + } |
| + |
| + // And an ORDINARY app deletes exactly as it always did. |
| + wu := instancesURL(fx, "bab", "widget") |
| + if status, _, out := rawReq(t, http.MethodPost, wu, token, |
| + `{"host":"box1.widget.tito.io","region":"ie"}`); status != http.StatusCreated { |
| + t.Fatalf("ordinary create = %d %s", status, out) |
| + } |
| + if status, _, out := rawReq(t, http.MethodDelete, wu+"/box1.widget.tito.io", token, ""); status != http.StatusOK { |
| + t.Fatalf("ordinary delete = %d %s, want 200 — the flag is not a fleet-wide freeze", status, out) |
| + } |
| +} |
| + |
| +// TestControlPlaneAppAllowsAPersonToCreateAndPatchItsInstance is the half |
| +// that must keep working, and the runbook step this whole slice unblocks: |
| +// the console has no instance record because it predates provisioning, and |
| +// `carlos deploy -app console` cannot resolve a host until a PERSON writes |
| +// one. A create gate here would close the door the flag exists to keep open. |
| +func TestControlPlaneAppAllowsAPersonToCreateAndPatchItsInstance(t *testing.T) { |
| + fx, token := cpInstanceFixture(t, "code-cp-inst-person") |
| + u := instancesURL(fx, "bab", "console") |
| + if status, _, out := rawReq(t, http.MethodPost, u, token, |
| + `{"host":"box1.cp.tito.io","region":"ie","kind":"service","addrs":["127.0.0.1:9001"]}`); status != http.StatusCreated { |
| + t.Fatalf("a PERSON creating the control plane's instance record = %d %s, want 201 — this is the runbook step", status, out) |
| + } |
| + if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "console", "box1.cp.tito.io"); err != nil { |
| + t.Fatalf("the record a person created is not there: %v", err) |
| + } |
| + if status, _, out := rawReq(t, http.MethodPatch, updateURL(fx, "bab", "console", "box1.cp.tito.io"), |
| + token, `{"addrs":["127.0.0.1:9002"]}`); status != http.StatusOK { |
| + t.Fatalf("a PERSON patching the control plane's instance record = %d %s, want 200", status, out) |
| + } |
| + rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "console", "box1.cp.tito.io") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(rec.Addrs) != 1 || rec.Addrs[0] != "127.0.0.1:9002" { |
| + t.Fatalf("addrs after a person's patch = %v, want the requested one", rec.Addrs) |
| + } |
| +} |
| + |
| +// TestControlPlaneAppRefusesAServiceInstanceCreate: the record a create |
| +// writes is the thing the reconciler then owns, so minting one for the |
| +// control plane is acquiring authority over the console's route. A person |
| +// may; a CI credential may not. |
| +func TestControlPlaneAppRefusesAServiceInstanceCreate(t *testing.T) { |
| + fx, _ := cpInstanceFixture(t, "code-cp-inst-svc-create") |
| + svc := serviceToken(t, fx.st, OpsAccountID, "ci", "console", svcrole.Instance) |
| + status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "console"), svc, |
| + `{"host":"box2.cp.tito.io","region":"ie"}`) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("a SERVICE creating the control plane's instance record = %d %s, want 403", status, out) |
| + } |
| + if !strings.Contains(out, "a person must") { |
| + t.Fatalf("refusal %q does not say a person is the principal this needs", out) |
| + } |
| + if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "console", "box2.cp.tito.io"); err == nil { |
| + t.Fatal("a record exists after only a refused service create") |
| + } |
| + |
| + // The same credential shape, bound to an ordinary app, creates freely: |
| + // nothing about service provisioning became worse for a tenant. |
| + ws := serviceToken(t, fx.st, OpsAccountID, "ci-widget", "widget", svcrole.Instance) |
| + if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "widget"), ws, |
| + `{"host":"box2.widget.tito.io","region":"ie"}`); status != http.StatusCreated { |
| + t.Fatalf("a SERVICE creating an ORDINARY app's instance = %d %s, want 201", status, out) |
| + } |
| +} |
| + |
| +// TestControlPlaneAppRefusesAServiceInstancePatch: the provisioning |
| +// reconciler converges a route's Channel and Environment FROM this record on |
| +// every pass, so a PATCH repoints the control plane. Same split as create. |
| +func TestControlPlaneAppRefusesAServiceInstancePatch(t *testing.T) { |
| + fx, token := cpInstanceFixture(t, "code-cp-inst-svc-patch") |
| + u := instancesURL(fx, "bab", "console") |
| + if status, _, out := rawReq(t, http.MethodPost, u, token, |
| + `{"host":"box3.cp.tito.io","region":"ie","kind":"service","addrs":["127.0.0.1:9001"]}`); status != http.StatusCreated { |
| + t.Fatalf("create = %d %s", status, out) |
| + } |
| + svc := serviceToken(t, fx.st, OpsAccountID, "ci", "console", svcrole.Instance) |
| + status, _, out := rawReq(t, http.MethodPatch, updateURL(fx, "bab", "console", "box3.cp.tito.io"), svc, |
| + `{"addrs":["10.0.0.1:1"]}`) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("a SERVICE patching the control plane's instance record = %d %s, want 403", status, out) |
| + } |
| + if !strings.Contains(out, "a person must") { |
| + t.Fatalf("refusal %q does not say a person is the principal this needs", out) |
| + } |
| + rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "console", "box3.cp.tito.io") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(rec.Addrs) != 1 || rec.Addrs[0] != "127.0.0.1:9001" { |
| + t.Fatalf("addrs after a refused service patch = %v, want the created pair untouched", rec.Addrs) |
| + } |
| + |
| + // An ordinary app's record is still a service's to patch. |
| + ws := serviceToken(t, fx.st, OpsAccountID, "ci-widget", "widget", svcrole.Instance) |
| + if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "widget"), ws, |
| + `{"host":"box3.widget.tito.io","region":"ie","kind":"service","addrs":["127.0.0.1:9001"]}`); status != http.StatusCreated { |
| + t.Fatalf("ordinary create = %d %s", status, out) |
| + } |
| + if status, _, out := rawReq(t, http.MethodPatch, updateURL(fx, "bab", "widget", "box3.widget.tito.io"), ws, |
| + `{"addrs":["127.0.0.1:9002"]}`); status != http.StatusOK { |
| + t.Fatalf("a SERVICE patching an ORDINARY app's instance = %d %s, want 200", status, out) |
| + } |
| +} |
| + |
| +// TestControlPlaneInstanceRefusalIsNotAnOracle: the three guards sit AFTER |
| +// the family's membership gate, so a stranger meets the byte-identical 404 |
| +// and never the refusal. A control-plane app must not be discoverable by |
| +// asking a door which sentence it answers with. |
| +func TestControlPlaneInstanceRefusalIsNotAnOracle(t *testing.T) { |
| + fx, _ := cpInstanceFixture(t, "code-cp-inst-oracle") |
| + eve := cliToken(t, fx, "eve@keymail.dev", "code-cp-inst-eve") |
| + want := canonicalNotFound(t) |
| + u := instancesURL(fx, "bab", "console") |
| + for _, c := range []struct{ what, method, url, body string }{ |
| + {"non-member create on a flagged app", http.MethodPost, u, `{"host":"box9.cp.tito.io","region":"ie"}`}, |
| + {"non-member patch on a flagged app", http.MethodPatch, u + "/box9.cp.tito.io", `{"addrs":[]}`}, |
| + {"non-member delete on a flagged app", http.MethodDelete, u + "/box9.cp.tito.io", ""}, |
| + } { |
| + status, h, out := rawReq(t, c.method, c.url, eve, c.body) |
| + assertNotFound(t, c.what, want, status, h, out) |
| + } |
| +} |