| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "net/http" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | "time" |
| 9 | |
| 10 | "github.com/carlosframework/platform/internal/domains" |
| 11 | "github.com/carlosframework/platform/internal/provision" |
| 12 | "github.com/carlosframework/platform/internal/release" |
| 13 | ) |
| 14 | |
| 15 | // canaryDoorFixture is a console with the platform apps domain set, and two |
| 16 | // apps in the ops account ("bar" and "foo-bar") with instances enabled on |
| 17 | // the account's platform subdomain, the way `carlos instances enable` pins |
| 18 | // them. It returns the fixture, a member token and the manifest domain. |
| 19 | func canaryDoorFixture(t *testing.T, code string, apps ...string) (*consoleFixture, string, string) { |
| 20 | t.Helper() |
| 21 | fx, member := instanceFixture(t, code) |
| 22 | fx.srv.cfg.AppsDomain = "oncarlos.test" |
| 23 | now := time.Now() |
| 24 | for _, a := range apps { |
| 25 | if _, err := fx.st.CreateAppIn(OpsAccountID, a, "paul@keymail.dev", now); err != nil { |
| 26 | t.Fatal(err) |
| 27 | } |
| 28 | } |
| 29 | for _, a := range append([]string{"titogo"}, apps...) { |
| 30 | if status, _, out := rawReq(t, http.MethodPut, enableURL(fx, "bab", a), member, `{}`); status != http.StatusOK { |
| 31 | t.Fatalf("enable %s = %d %s", a, status, out) |
| 32 | } |
| 33 | } |
| 34 | return fx, member, fx.sqid + ".oncarlos.test" |
| 35 | } |
| 36 | |
| 37 | // The flat canary host is <canary>-<app>.<domain>: one label, so the create |
| 38 | // door that insists on one label accepts it. This is the door half of |
| 39 | // discussion 207: the host `carlos canary` builds must pass ValidHost. |
| 40 | func TestAPIInstanceCreateAcceptsAFlatCanaryHost(t *testing.T) { |
| 41 | fx, member, domain := canaryDoorFixture(t, "code-canary-flat") |
| 42 | status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member, |
| 43 | `{"host":"new-queue-titogo.`+domain+`","channel":"canary/new-queue"}`) |
| 44 | if status != http.StatusCreated { |
| 45 | t.Fatalf("flat canary host create = %d %s, want 201", status, out) |
| 46 | } |
| 47 | // The two-label form stays refused: ValidHost is a tenancy boundary and |
| 48 | // is not loosened for canaries. |
| 49 | status, _, out = rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member, |
| 50 | `{"host":"other.titogo.`+domain+`","channel":"canary/other"}`) |
| 51 | if status != http.StatusBadRequest || !strings.Contains(out, "invalid host") { |
| 52 | t.Fatalf("two-label host create = %d %s, want 400 invalid host", status, out) |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | // A canary named "foo" on app "bar" lands on foo-bar.<domain>, which is also |
| 57 | // the platform apex of an app called "foo-bar". Flattening makes that |
| 58 | // reachable, so both orders are pinned: the canary cannot take a live or |
| 59 | // trashed app's apex, and an app cannot be created over a canary that |
| 60 | // already sits on its apex. |
| 61 | func TestFlatCanaryHostCannotTakeAnotherAppsApex(t *testing.T) { |
| 62 | fx, member, domain := canaryDoorFixture(t, "code-canary-apex", "bar", "foo-bar") |
| 63 | |
| 64 | // canary "foo" of app "bar": host foo-bar, app foo-bar's apex. |
| 65 | status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "bar"), member, |
| 66 | `{"host":"foo-bar.`+domain+`","channel":"canary/foo"}`) |
| 67 | if status != http.StatusConflict || !strings.Contains(out, "foo-bar") || !strings.Contains(out, "platform apex") { |
| 68 | t.Fatalf("canary onto a live app's apex = %d %s, want 409 naming the apex", status, out) |
| 69 | } |
| 70 | if err := fx.st.TrashAppRow(OpsAccountID, "foo-bar", time.Now()); err != nil { |
| 71 | t.Fatal(err) |
| 72 | } |
| 73 | status, _, out = rawReq(t, http.MethodPost, instancesURL(fx, "bab", "bar"), member, |
| 74 | `{"host":"foo-bar.`+domain+`","channel":"canary/foo"}`) |
| 75 | if status != http.StatusConflict || !strings.Contains(out, "trash") { |
| 76 | t.Fatalf("canary onto a trashed app's apex = %d %s, want 409 naming the trash", status, out) |
| 77 | } |
| 78 | if err := fx.st.RestoreAppRow(OpsAccountID, "foo-bar"); err != nil { |
| 79 | t.Fatal(err) |
| 80 | } |
| 81 | if _, err := provision.GetRecord(context.Background(), fx.rel, release.OpsAccount, "bar", "foo-bar."+domain); err == nil { |
| 82 | t.Fatal("a refused canary left a record behind") |
| 83 | } |
| 84 | |
| 85 | // The other order: the canary exists while no app owns the name, and an |
| 86 | // app arrives under it afterwards. |
| 87 | status, _, out = rawReq(t, http.MethodPost, instancesURL(fx, "bab", "bar"), member, |
| 88 | `{"host":"qux-bar.`+domain+`","channel":"canary/qux"}`) |
| 89 | if status != http.StatusCreated { |
| 90 | t.Fatalf("canary on a free label = %d %s, want 201", status, out) |
| 91 | } |
| 92 | if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new", map[string]string{"name": "qux-bar"}, member); status != http.StatusConflict { |
| 93 | t.Fatalf("creating an app over a canary's host = %d %v, want 409", status, out) |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | // Two canaries of two apps can flatten to the same label: canary "a" of app |
| 98 | // "b-c" and canary "a-b" of app "c" are both a-b-c. Instance records are |
| 99 | // keyed per app, so nothing about the key stops the second one, and the |
| 100 | // provisioning pass would quietly keep whichever record it lists first. The |
| 101 | // door refuses a host another app of the account already holds, whatever |
| 102 | // its channel, and in both directions. |
| 103 | func TestFlatCanaryHostsOfTwoAppsCannotCollide(t *testing.T) { |
| 104 | fx, member, domain := canaryDoorFixture(t, "code-canary-collide", "b-c", "c") |
| 105 | const host = "a-b-c." |
| 106 | |
| 107 | status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "b-c"), member, |
| 108 | `{"host":"`+host+domain+`","channel":"canary/a"}`) |
| 109 | if status != http.StatusCreated { |
| 110 | t.Fatalf("first canary = %d %s, want 201", status, out) |
| 111 | } |
| 112 | status, _, out = rawReq(t, http.MethodPost, instancesURL(fx, "bab", "c"), member, |
| 113 | `{"host":"`+host+domain+`","channel":"canary/a-b"}`) |
| 114 | if status != http.StatusConflict || !strings.Contains(out, "b-c") { |
| 115 | t.Fatalf("colliding canary of another app = %d %s, want 409 naming app b-c", status, out) |
| 116 | } |
| 117 | if _, err := provision.GetRecord(context.Background(), fx.rel, release.OpsAccount, "c", host+domain); err == nil { |
| 118 | t.Fatal("the refused canary left a record under app c") |
| 119 | } |
| 120 | |
| 121 | // An ordinary instance of another app at a host a canary holds is the |
| 122 | // same collision, as is the reverse. |
| 123 | status, _, out = rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member, |
| 124 | `{"host":"`+host+domain+`"}`) |
| 125 | if status != http.StatusConflict { |
| 126 | t.Fatalf("ordinary instance on a canary's host = %d %s, want 409", status, out) |
| 127 | } |
| 128 | |
| 129 | // The holder itself is told "already exists", as ever: this is not a |
| 130 | // sibling's host. |
| 131 | status, _, out = rawReq(t, http.MethodPost, instancesURL(fx, "bab", "b-c"), member, |
| 132 | `{"host":"`+host+domain+`","channel":"canary/a"}`) |
| 133 | if status != http.StatusConflict || !strings.Contains(out, "already exists") { |
| 134 | t.Fatalf("re-create by the holder = %d %s, want 409 already exists", status, out) |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | // An attached alias is a hostname claim (domains.ClaimKey) with no instance |
| 139 | // record, and no route until the domains pass derives one, so the holder |
| 140 | // check cannot see it; a route minted at that host would shadow the alias at |
| 141 | // the edge. The create door reads the claim itself: another app's claim, in |
| 142 | // this account or another, refuses the create (the other account's without |
| 143 | // saying whose), this app's own claim does not, and a claim that cannot be |
| 144 | // read fails closed. |
| 145 | func TestInstanceCreateRefusesAHostAnotherAppHasClaimed(t *testing.T) { |
| 146 | fx, member, domain := canaryDoorFixture(t, "code-canary-claim", "other") |
| 147 | ctx := context.Background() |
| 148 | claim := func(host, acct, app string) { |
| 149 | t.Helper() |
| 150 | if _, _, err := domains.AttachClaim(ctx, fx.rel, domains.Claim{Schema: 1, Host: host, Account: acct, App: app, |
| 151 | CreatedAt: time.Now().UTC().Format(time.RFC3339)}); err != nil { |
| 152 | t.Fatal(err) |
| 153 | } |
| 154 | } |
| 155 | create := func(label string) (int, string) { |
| 156 | t.Helper() |
| 157 | status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member, |
| 158 | `{"host":"`+label+`.`+domain+`","channel":"canary/`+label+`"}`) |
| 159 | return status, out |
| 160 | } |
| 161 | |
| 162 | claim("probe-titogo."+domain, "bab", "other") |
| 163 | if status, out := create("probe-titogo"); status != http.StatusConflict || !strings.Contains(out, "other") { |
| 164 | t.Fatalf("create over a sibling's claim = %d %s, want 409 naming app other", status, out) |
| 165 | } |
| 166 | |
| 167 | claim("away-titogo."+domain, "zzzz", "other") |
| 168 | status, out := create("away-titogo") |
| 169 | if status != http.StatusConflict || strings.Contains(out, "zzzz") || strings.Contains(out, "other") { |
| 170 | t.Fatalf("create over another account's claim = %d %s, want 409 that names neither", status, out) |
| 171 | } |
| 172 | |
| 173 | claim("mine-titogo."+domain, "bab", "titogo") |
| 174 | if status, out := create("mine-titogo"); status != http.StatusCreated { |
| 175 | t.Fatalf("create over this app's own claim = %d %s, want 201", status, out) |
| 176 | } |
| 177 | |
| 178 | if _, err := fx.rel.Put(ctx, domains.ClaimKey("junk-titogo."+domain), []byte("not json"), ""); err != nil { |
| 179 | t.Fatal(err) |
| 180 | } |
| 181 | if status, out := create("junk-titogo"); status != http.StatusInternalServerError { |
| 182 | t.Fatalf("create with an unreadable claim = %d %s, want 500 (fail closed)", status, out) |
| 183 | } |
| 184 | if _, err := provision.GetRecord(ctx, fx.rel, release.OpsAccount, "titogo", "probe-titogo."+domain); err == nil { |
| 185 | t.Fatal("a refused create left a record behind") |
| 186 | } |
| 187 | } |
| 188 | |