| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "net/http" |
| 6 | "net/url" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/registry" |
| 12 | ) |
| 13 | |
| 14 | // A name whose platform apex a sibling already holds is not free. The |
| 15 | // instance door refuses a sibling's apex once the sibling exists; this is the |
| 16 | // other order — the instance first, while the name was free, and the app after |
| 17 | // it — which would leave the new app with no URL of its own for ever. |
| 18 | func TestANameWhoseApexASiblingHoldsIsNotFree(t *testing.T) { |
| 19 | fx, member := instanceFixture(t, "code-apex-claim") |
| 20 | fx.srv.cfg.AppsDomain = "oncarlos.test" |
| 21 | domain := fx.sqid + ".oncarlos.test" |
| 22 | if status, _, out := rawReq(t, http.MethodPut, enableURL(fx, fx.sqid, "titogo"), member, `{}`); status != http.StatusOK { |
| 23 | t.Fatalf("enable = %d %s", status, out) |
| 24 | } |
| 25 | // While no app is called acme, titogo may take acme's label. |
| 26 | if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, fx.sqid, "titogo"), member, `{"host":"acme.`+domain+`"}`); status != http.StatusCreated { |
| 27 | t.Fatalf("instance at a free label = %d %s", status, out) |
| 28 | } |
| 29 | newApp := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/new" |
| 30 | |
| 31 | // An instance record is enough: the reconciler would make it a route. |
| 32 | status, out := postJSON(t, newApp, map[string]string{"name": "acme"}, member) |
| 33 | if status != http.StatusConflict { |
| 34 | t.Fatalf("creating acme over titogo's instance = %d %v, want 409", status, out) |
| 35 | } |
| 36 | if msg, _ := out["error"].(string); !strings.Contains(msg, "acme."+domain) || !strings.Contains(msg, "titogo") { |
| 37 | t.Errorf("the refusal should name the address and who holds it: %q", msg) |
| 38 | } |
| 39 | if has, _ := fx.st.HasAppIn(OpsAccountID, "acme"); has { |
| 40 | t.Error("acme was created") |
| 41 | } |
| 42 | |
| 43 | // So is a route on its own, whatever put it there. |
| 44 | fx.addRoute(t, registry.Route{Host: "beta." + domain, App: "titogo", Account: fx.sqid, Kind: "instance"}) |
| 45 | if status, out := postJSON(t, newApp, map[string]string{"name": "beta"}, member); status != http.StatusConflict { |
| 46 | t.Fatalf("creating beta over titogo's route = %d %v, want 409", status, out) |
| 47 | } |
| 48 | |
| 49 | // A route that names no app holds the host just as firmly: the domains |
| 50 | // pass defers to it all the same. |
| 51 | fx.addRoute(t, registry.Route{Host: "delta." + domain, Account: fx.sqid, Kind: "proxy"}) |
| 52 | status, out = postJSON(t, newApp, map[string]string{"name": "delta"}, member) |
| 53 | if status != http.StatusConflict { |
| 54 | t.Fatalf("creating delta over an app-less route = %d %v, want 409", status, out) |
| 55 | } |
| 56 | if msg, _ := out["error"].(string); !strings.Contains(msg, "no app") { |
| 57 | t.Errorf("the refusal should say the route has no app: %q", msg) |
| 58 | } |
| 59 | |
| 60 | // Nor is a route that wears the name but serves another account the |
| 61 | // name's own. |
| 62 | fx.addRoute(t, registry.Route{Host: "epsilon." + domain, App: "epsilon", Account: "zzzz", Kind: "instance"}) |
| 63 | status, out = postJSON(t, newApp, map[string]string{"name": "epsilon"}, member) |
| 64 | if status != http.StatusConflict { |
| 65 | t.Fatalf("creating epsilon over another account's route = %d %v, want 409", status, out) |
| 66 | } |
| 67 | if msg, _ := out["error"].(string); !strings.Contains(msg, "zzzz") { |
| 68 | t.Errorf("the refusal should name the account the route serves: %q", msg) |
| 69 | } |
| 70 | |
| 71 | // Trashing the sibling does not free the name: its record survives, and |
| 72 | // restoring it would bring the instance back. |
| 73 | if err := fx.st.TrashAppRow(OpsAccountID, "titogo", time.Now()); err != nil { |
| 74 | t.Fatal(err) |
| 75 | } |
| 76 | status, out = postJSON(t, newApp, map[string]string{"name": "acme"}, member) |
| 77 | if status != http.StatusConflict { |
| 78 | t.Fatalf("creating acme over a trashed titogo's instance = %d %v, want 409", status, out) |
| 79 | } |
| 80 | if msg, _ := out["error"].(string); !strings.Contains(msg, "trash") { |
| 81 | t.Errorf("the refusal should say titogo is in the trash: %q", msg) |
| 82 | } |
| 83 | |
| 84 | // A name nobody holds is still just a name. |
| 85 | if status, out := postJSON(t, newApp, map[string]string{"name": "gamma"}, member); status != http.StatusOK { |
| 86 | t.Fatalf("creating a free name = %d %v, want 200", status, out) |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | // Renaming onto a name whose apex a sibling holds is the same arrival by |
| 91 | // another door, and is refused the same way. |
| 92 | func TestRenameOntoAHeldApexIsRefused(t *testing.T) { |
| 93 | fx := newConsoleFixture(t) |
| 94 | fx.srv.cfg.AppsDomain = "oncarlos.test" |
| 95 | freshApp(t, fx, "sprout") |
| 96 | fx.addRoute(t, registry.Route{Host: "sapling." + fx.sqid + ".oncarlos.test", App: "kass", Account: fx.sqid, Kind: "instance"}) |
| 97 | c := signInPaul(t, fx) |
| 98 | csrf := sessionToken(t, fx, c) |
| 99 | form := url.Values{"csrf": {csrf}, "to": {"sapling"}, "confirmed": {"1"}} |
| 100 | if status, loc, body := postForm(t, c, fx.u("/apps/sprout/rename"), form); strings.Contains(body, "Rename sprout to sapling?") { |
| 101 | t.Fatalf("offered the rename onto a held apex = %d -> %q", status, loc) |
| 102 | } |
| 103 | if has, _ := fx.st.HasAppIn(OpsAccountID, "sapling"); has { |
| 104 | t.Error("sprout was renamed onto an apex kass holds") |
| 105 | } |
| 106 | if has, _ := fx.st.HasAppIn(OpsAccountID, "sprout"); !has { |
| 107 | t.Error("sprout should be untouched") |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | // Moving an app into an account where another app holds its apex there is |
| 112 | // the third arrival, and is refused before the card. |
| 113 | func TestMoveIntoAnAccountWhereTheApexIsHeldIsRefused(t *testing.T) { |
| 114 | fx := newConsoleFixture(t) |
| 115 | fx.srv.cfg.AppsDomain = "oncarlos.test" |
| 116 | const destID = 4242 |
| 117 | if err := fx.st.CreateAccount(destID, "cdef", "Second", "paul@keymail.dev", time.Now()); err != nil { |
| 118 | t.Fatal(err) |
| 119 | } |
| 120 | stampKassRoutes(t, fx, "cdef") |
| 121 | fx.addRoute(t, registry.Route{Host: "kass.cdef.oncarlos.test", App: "other", Account: "cdef", Kind: "instance"}) |
| 122 | c := signInPaul(t, fx) |
| 123 | csrf := sessionToken(t, fx, c) |
| 124 | // Not confirmed: without the check this renders the move card, so the |
| 125 | // card's absence is the refusal. |
| 126 | status, _, body := postForm(t, c, fx.u("/apps/kass/move"), url.Values{"csrf": {csrf}}) |
| 127 | if strings.Contains(body, "Move kass to Second?") { |
| 128 | t.Fatalf("offered the move into an account where the apex is held = %d", status) |
| 129 | } |
| 130 | if has, _ := fx.st.HasAppIn(destID, "kass"); has { |
| 131 | t.Error("kass was moved") |
| 132 | } |
| 133 | if has, _ := fx.st.HasAppIn(OpsAccountID, "kass"); !has { |
| 134 | t.Error("kass should still be in its own account") |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | // At boot the console names every route already sitting at another app's |
| 139 | // apex — live or trashed, with another app, no app, or another account on |
| 140 | // the route — and nothing else: an app's own apex, a label no app wears, and |
| 141 | // hosts outside the apps domain are not squats. |
| 142 | func TestTheStartupReportNamesSquatsAndNothingElse(t *testing.T) { |
| 143 | fx := newConsoleFixture(t) |
| 144 | fx.srv.cfg.AppsDomain = "oncarlos.test" |
| 145 | now := time.Now() |
| 146 | for _, app := range []string{"acme", "parked", "blank", "elsewhere"} { |
| 147 | if _, err := fx.st.CreateAppIn(OpsAccountID, app, "paul@keymail.dev", now); err != nil { |
| 148 | t.Fatal(err) |
| 149 | } |
| 150 | } |
| 151 | if err := fx.st.TrashAppRow(OpsAccountID, "parked", now); err != nil { |
| 152 | t.Fatal(err) |
| 153 | } |
| 154 | d := fx.sqid + ".oncarlos.test" |
| 155 | for _, rt := range []registry.Route{ |
| 156 | {Host: "acme." + d, App: "titogo", Account: fx.sqid, Kind: "instance"}, // squat on a live app |
| 157 | {Host: "parked." + d, App: "titogo", Account: fx.sqid, Kind: "instance"}, // squat on a trashed one |
| 158 | {Host: "blank." + d, Account: fx.sqid, Kind: "proxy"}, // squat with no app |
| 159 | {Host: "elsewhere." + d, App: "elsewhere", Account: "zzzz", Kind: "proxy"}, // right name, wrong account |
| 160 | {Host: "titogo." + d, App: "titogo", Account: fx.sqid, Kind: "instance"}, // its own apex |
| 161 | {Host: "nobody." + d, App: "titogo", Account: fx.sqid, Kind: "instance"}, // no app by that name |
| 162 | {Host: "acme.example.com", App: "titogo", Account: fx.sqid, Kind: "instance"}, |
| 163 | } { |
| 164 | fx.addRoute(t, rt) |
| 165 | } |
| 166 | got := fx.srv.apexSquats(context.Background()) |
| 167 | if len(got) != 4 { |
| 168 | t.Fatalf("report = %q, want the four squats", got) |
| 169 | } |
| 170 | joined := strings.Join(got, "\n") |
| 171 | for _, want := range []string{"acme." + d, "parked." + d, "in the trash", "belongs to titogo", |
| 172 | "blank." + d, "belongs to no app", "elsewhere." + d, "(zzzz)"} { |
| 173 | if !strings.Contains(joined, want) { |
| 174 | t.Errorf("report missing %q:\n%s", want, joined) |
| 175 | } |
| 176 | } |
| 177 | } |
| 178 | |