package console import ( "context" "encoding/json" "net/http" "slices" "strings" "testing" "github.com/carlosframework/platform/internal/provision" ) // The create door stamps a named config bundle onto the record (2026-09-04). // // Why it matters, and why it was worth changing a door that deliberately // omitted the field: config in CARLOS is per-app, so a second instance // standing alongside a live one inherits the default bundle — including // whatever single origin the app's framework compares the request's Origin // header against. Every POST to the new host then fails closed while every // GET looks perfectly healthy. `carlos canary` mints exactly such a route. func TestAPIInstanceCreateStampsEnvironment(t *testing.T) { fx, token := instanceFixture(t, "code-inst-env-create") enableInstances(t, fx, "titogo", titoManifest()) u := instancesURL(fx, "bab", "titogo") status, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"canary.go.tito.io","region":"ie","environment":"jam-canary"}`) if status != http.StatusCreated { t.Fatalf("create with environment = %d %s, want 201", status, out) } var echoed provision.Record if err := json.Unmarshal([]byte(out), &echoed); err != nil { t.Fatalf("create body %s: %v", out, err) } if echoed.Environment != "jam-canary" { t.Fatalf("echoed environment = %q, want jam-canary", echoed.Environment) } stored, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "canary.go.tito.io") if err != nil { t.Fatal(err) } if stored.Environment != "jam-canary" { t.Fatalf("stored environment = %q, want jam-canary — the record is what convergeOwned reads", stored.Environment) } } // An omitted environment still means the app's default bundle, byte for byte // what this door did before the field existed. Every record in production was // written without it. func TestAPIInstanceCreateWithoutEnvironmentIsUnchanged(t *testing.T) { fx, token := instanceFixture(t, "code-inst-env-absent") enableInstances(t, fx, "titogo", titoManifest()) status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), token, `{"host":"plain.go.tito.io","region":"ie"}`) if status != http.StatusCreated { t.Fatalf("create = %d %s, want 201", status, out) } stored, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "plain.go.tito.io") if err != nil { t.Fatal(err) } if stored.Environment != "" { t.Fatalf("stored environment = %q, want empty for an omitted field", stored.Environment) } } // A malformed name is ValidateShape's own 400, at the same door the box's // reconcile pass re-checks against — neither side trusts the other about // what is in the bucket. func TestAPIInstanceCreateRefusesBadEnvironment(t *testing.T) { fx, token := instanceFixture(t, "code-inst-env-bad") enableInstances(t, fx, "titogo", titoManifest()) status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), token, `{"host":"bad.go.tito.io","region":"ie","environment":"Not A Bundle"}`) if status != http.StatusBadRequest { t.Fatalf("create with a malformed environment = %d %s, want 400", status, out) } if !strings.Contains(out, "environment") { t.Errorf("refusal %s does not name the offending field", out) } } // The PATCH door's fourth field: bind an existing route to a bundle, and // clear it again. This is the door `carlos instances set-environment` and // `carlos canary --environment` both reach, and the one that closes the gap // convergeOwned opened by reverting `carlos route -environment`. func TestAPIInstanceUpdateEnvironmentRoundTrip(t *testing.T) { fx, token := instanceFixture(t, "code-inst-env-round") letStateUpstreams(t, fx, "alice@keymail.dev") enableInstances(t, fx, "titogo", titoManifest()) u := instancesURL(fx, "bab", "titogo") if status, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"env.go.tito.io","region":"ie","addrs":["127.0.0.1:7001"]}`); status != http.StatusCreated { t.Fatalf("create = %d %s", status, out) } afterCreate := serial(t, fx) pu := updateURL(fx, "bab", "titogo", "env.go.tito.io") status, _, out := rawReq(t, http.MethodPatch, pu, token, `{"environment":"jam-canary"}`) if status != http.StatusOK { t.Fatalf("patch environment = %d %s, want 200", status, out) } var echoed provision.Record if err := json.Unmarshal([]byte(out), &echoed); err != nil { t.Fatalf("patch body %s: %v", out, err) } if echoed.Environment != "jam-canary" { t.Fatalf("echoed environment = %q, want jam-canary", echoed.Environment) } // An environment-only patch must leave the upstream set exactly alone — // the same independence the addrs/probe/steering fields already have. if !slices.Equal(echoed.Addrs, []string{"127.0.0.1:7001"}) { t.Fatalf("environment-only patch moved addrs: %v", echoed.Addrs) } stored, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "env.go.tito.io") if err != nil { t.Fatal(err) } if stored.Environment != "jam-canary" { t.Fatalf("stored environment = %q, want jam-canary", stored.Environment) } if got := serial(t, fx); got != afterCreate+1 { t.Fatalf("serial after patch = %d, want exactly one bump past %d", got, afterCreate) } afterBind := serial(t, fx) // "" is a real request — return this route to the app's default bundle — // and not a "leave it alone". That is why the client sends the field // without omitempty. status, _, out = rawReq(t, http.MethodPatch, pu, token, `{"environment":""}`) if status != http.StatusOK { t.Fatalf("patch environment off = %d %s, want 200", status, out) } stored, err = provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "env.go.tito.io") if err != nil { t.Fatal(err) } if stored.Environment != "" { t.Fatalf("stored environment after clearing = %q, want empty", stored.Environment) } if !slices.Equal(stored.Addrs, []string{"127.0.0.1:7001"}) { t.Fatalf("environment-only patch moved addrs: %v", stored.Addrs) } if got := serial(t, fx); got != afterBind+1 { t.Fatalf("serial after clearing = %d, want exactly one bump past %d", got, afterBind) } } // The fourth field joins the "nothing to update" rail rather than making it // optional — a body naming none of the four is still a 400 that costs no // bucket round trip and no fleet-wide serial bump. func TestAPIInstanceUpdateEmptyBodyNamesEnvironment(t *testing.T) { fx, token := instanceFixture(t, "code-inst-env-empty") enableInstances(t, fx, "titogo", titoManifest()) if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), token, `{"host":"none.go.tito.io","region":"ie"}`); status != http.StatusCreated { t.Fatalf("create = %d %s", status, out) } before := serial(t, fx) status, _, out := rawReq(t, http.MethodPatch, updateURL(fx, "bab", "titogo", "none.go.tito.io"), token, `{}`) if status != http.StatusBadRequest { t.Fatalf("patch with no fields = %d %s, want 400", status, out) } if !strings.Contains(out, "environment") { t.Errorf("empty-body refusal = %s, want it to name environment among the patchable fields", out) } if got := serial(t, fx); got != before { t.Errorf("serial after a refused empty patch = %d, want it unmoved from %d", got, before) } } // A malformed name is refused on the PATCH door too, and the stored record is // left exactly as it was — a refusal must not half-apply. func TestAPIInstanceUpdateRefusesBadEnvironment(t *testing.T) { fx, token := instanceFixture(t, "code-inst-env-patch-bad") enableInstances(t, fx, "titogo", titoManifest()) if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), token, `{"host":"keep.go.tito.io","region":"ie","environment":"good-one"}`); status != http.StatusCreated { t.Fatalf("create = %d %s", status, out) } status, _, out := rawReq(t, http.MethodPatch, updateURL(fx, "bab", "titogo", "keep.go.tito.io"), token, `{"environment":"Not A Bundle"}`) if status != http.StatusBadRequest { t.Fatalf("patch with a malformed environment = %d %s, want 400", status, out) } stored, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "keep.go.tito.io") if err != nil { t.Fatal(err) } if stored.Environment != "good-one" { t.Fatalf("stored environment = %q after a refused patch, want the original good-one", stored.Environment) } }