package console import ( "context" "net/http" "strings" "testing" "time" "github.com/carlosframework/platform/internal/domains" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" ) // canaryDoorFixture is a console with the platform apps domain set, and two // apps in the ops account ("bar" and "foo-bar") with instances enabled on // the account's platform subdomain, the way `carlos instances enable` pins // them. It returns the fixture, a member token and the manifest domain. func canaryDoorFixture(t *testing.T, code string, apps ...string) (*consoleFixture, string, string) { t.Helper() fx, member := instanceFixture(t, code) fx.srv.cfg.AppsDomain = "oncarlos.test" now := time.Now() for _, a := range apps { if _, err := fx.st.CreateAppIn(OpsAccountID, a, "paul@keymail.dev", now); err != nil { t.Fatal(err) } } for _, a := range append([]string{"titogo"}, apps...) { if status, _, out := rawReq(t, http.MethodPut, enableURL(fx, "bab", a), member, `{}`); status != http.StatusOK { t.Fatalf("enable %s = %d %s", a, status, out) } } return fx, member, fx.sqid + ".oncarlos.test" } // The flat canary host is -.: one label, so the create // door that insists on one label accepts it. This is the door half of // discussion 207: the host `carlos canary` builds must pass ValidHost. func TestAPIInstanceCreateAcceptsAFlatCanaryHost(t *testing.T) { fx, member, domain := canaryDoorFixture(t, "code-canary-flat") status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member, `{"host":"new-queue-titogo.`+domain+`","channel":"canary/new-queue"}`) if status != http.StatusCreated { t.Fatalf("flat canary host create = %d %s, want 201", status, out) } // The two-label form stays refused: ValidHost is a tenancy boundary and // is not loosened for canaries. status, _, out = rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member, `{"host":"other.titogo.`+domain+`","channel":"canary/other"}`) if status != http.StatusBadRequest || !strings.Contains(out, "invalid host") { t.Fatalf("two-label host create = %d %s, want 400 invalid host", status, out) } } // A canary named "foo" on app "bar" lands on foo-bar., which is also // the platform apex of an app called "foo-bar". Flattening makes that // reachable, so both orders are pinned: the canary cannot take a live or // trashed app's apex, and an app cannot be created over a canary that // already sits on its apex. func TestFlatCanaryHostCannotTakeAnotherAppsApex(t *testing.T) { fx, member, domain := canaryDoorFixture(t, "code-canary-apex", "bar", "foo-bar") // canary "foo" of app "bar": host foo-bar, app foo-bar's apex. status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "bar"), member, `{"host":"foo-bar.`+domain+`","channel":"canary/foo"}`) if status != http.StatusConflict || !strings.Contains(out, "foo-bar") || !strings.Contains(out, "platform apex") { t.Fatalf("canary onto a live app's apex = %d %s, want 409 naming the apex", status, out) } if err := fx.st.TrashAppRow(OpsAccountID, "foo-bar", time.Now()); err != nil { t.Fatal(err) } status, _, out = rawReq(t, http.MethodPost, instancesURL(fx, "bab", "bar"), member, `{"host":"foo-bar.`+domain+`","channel":"canary/foo"}`) if status != http.StatusConflict || !strings.Contains(out, "trash") { t.Fatalf("canary onto a trashed app's apex = %d %s, want 409 naming the trash", status, out) } if err := fx.st.RestoreAppRow(OpsAccountID, "foo-bar"); err != nil { t.Fatal(err) } if _, err := provision.GetRecord(context.Background(), fx.rel, release.OpsAccount, "bar", "foo-bar."+domain); err == nil { t.Fatal("a refused canary left a record behind") } // The other order: the canary exists while no app owns the name, and an // app arrives under it afterwards. status, _, out = rawReq(t, http.MethodPost, instancesURL(fx, "bab", "bar"), member, `{"host":"qux-bar.`+domain+`","channel":"canary/qux"}`) if status != http.StatusCreated { t.Fatalf("canary on a free label = %d %s, want 201", status, out) } if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new", map[string]string{"name": "qux-bar"}, member); status != http.StatusConflict { t.Fatalf("creating an app over a canary's host = %d %v, want 409", status, out) } } // Two canaries of two apps can flatten to the same label: canary "a" of app // "b-c" and canary "a-b" of app "c" are both a-b-c. Instance records are // keyed per app, so nothing about the key stops the second one, and the // provisioning pass would quietly keep whichever record it lists first. The // door refuses a host another app of the account already holds, whatever // its channel, and in both directions. func TestFlatCanaryHostsOfTwoAppsCannotCollide(t *testing.T) { fx, member, domain := canaryDoorFixture(t, "code-canary-collide", "b-c", "c") const host = "a-b-c." status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "b-c"), member, `{"host":"`+host+domain+`","channel":"canary/a"}`) if status != http.StatusCreated { t.Fatalf("first canary = %d %s, want 201", status, out) } status, _, out = rawReq(t, http.MethodPost, instancesURL(fx, "bab", "c"), member, `{"host":"`+host+domain+`","channel":"canary/a-b"}`) if status != http.StatusConflict || !strings.Contains(out, "b-c") { t.Fatalf("colliding canary of another app = %d %s, want 409 naming app b-c", status, out) } if _, err := provision.GetRecord(context.Background(), fx.rel, release.OpsAccount, "c", host+domain); err == nil { t.Fatal("the refused canary left a record under app c") } // An ordinary instance of another app at a host a canary holds is the // same collision, as is the reverse. status, _, out = rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member, `{"host":"`+host+domain+`"}`) if status != http.StatusConflict { t.Fatalf("ordinary instance on a canary's host = %d %s, want 409", status, out) } // The holder itself is told "already exists", as ever: this is not a // sibling's host. status, _, out = rawReq(t, http.MethodPost, instancesURL(fx, "bab", "b-c"), member, `{"host":"`+host+domain+`","channel":"canary/a"}`) if status != http.StatusConflict || !strings.Contains(out, "already exists") { t.Fatalf("re-create by the holder = %d %s, want 409 already exists", status, out) } } // An attached alias is a hostname claim (domains.ClaimKey) with no instance // record, and no route until the domains pass derives one, so the holder // check cannot see it; a route minted at that host would shadow the alias at // the edge. The create door reads the claim itself: another app's claim, in // this account or another, refuses the create (the other account's without // saying whose), this app's own claim does not, and a claim that cannot be // read fails closed. func TestInstanceCreateRefusesAHostAnotherAppHasClaimed(t *testing.T) { fx, member, domain := canaryDoorFixture(t, "code-canary-claim", "other") ctx := context.Background() claim := func(host, acct, app string) { t.Helper() if _, _, err := domains.AttachClaim(ctx, fx.rel, domains.Claim{Schema: 1, Host: host, Account: acct, App: app, CreatedAt: time.Now().UTC().Format(time.RFC3339)}); err != nil { t.Fatal(err) } } create := func(label string) (int, string) { t.Helper() status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member, `{"host":"`+label+`.`+domain+`","channel":"canary/`+label+`"}`) return status, out } claim("probe-titogo."+domain, "bab", "other") if status, out := create("probe-titogo"); status != http.StatusConflict || !strings.Contains(out, "other") { t.Fatalf("create over a sibling's claim = %d %s, want 409 naming app other", status, out) } claim("away-titogo."+domain, "zzzz", "other") status, out := create("away-titogo") if status != http.StatusConflict || strings.Contains(out, "zzzz") || strings.Contains(out, "other") { t.Fatalf("create over another account's claim = %d %s, want 409 that names neither", status, out) } claim("mine-titogo."+domain, "bab", "titogo") if status, out := create("mine-titogo"); status != http.StatusCreated { t.Fatalf("create over this app's own claim = %d %s, want 201", status, out) } if _, err := fx.rel.Put(ctx, domains.ClaimKey("junk-titogo."+domain), []byte("not json"), ""); err != nil { t.Fatal(err) } if status, out := create("junk-titogo"); status != http.StatusInternalServerError { t.Fatalf("create with an unreadable claim = %d %s, want 500 (fail closed)", status, out) } if _, err := provision.GetRecord(ctx, fx.rel, release.OpsAccount, "titogo", "probe-titogo."+domain); err == nil { t.Fatal("a refused create left a record behind") } }