| new file mode 100644 |
| index 000000000..b21d6e4ee |
| --- /dev/null |
| +++ b/internal/console/apiinstancescanary_test.go |
| @@ -0,0 +1,135 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "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 <canary>-<app>.<domain>: 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.<domain>, 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) |
| + } |
| +} |