| 1 | package console |
| 2 | |
| 3 | // The `carlos restart` door (issue #113). What is pinned here, in the order it |
| 4 | // would hurt if lost: the request actually lands in the bucket AND is |
| 5 | // announced (a request nobody hears is a restart that never happens); a |
| 6 | // stranger learns nothing from probing it; and the budget that keeps it from |
| 7 | // becoming a restart loop is spent per person. |
| 8 | |
| 9 | import ( |
| 10 | "context" |
| 11 | "encoding/json" |
| 12 | "errors" |
| 13 | "net/http" |
| 14 | "os" |
| 15 | "testing" |
| 16 | "time" |
| 17 | |
| 18 | "github.com/carlosframework/platform/internal/release" |
| 19 | ) |
| 20 | |
| 21 | // restartGenEtag is what every box watches: "" when nobody has ever asked. |
| 22 | func restartGenEtag(t *testing.T, fx *consoleFixture, app string) string { |
| 23 | t.Helper() |
| 24 | _, etag, err := fx.rel.Get(context.Background(), release.RestartGenKey(release.OpsAccount, app)) |
| 25 | if err != nil { |
| 26 | if errors.Is(err, os.ErrNotExist) { |
| 27 | return "" |
| 28 | } |
| 29 | t.Fatalf("read restart generation: %v", err) |
| 30 | } |
| 31 | return etag |
| 32 | } |
| 33 | |
| 34 | func restartFixture(t *testing.T, code string) (*consoleFixture, string) { |
| 35 | t.Helper() |
| 36 | fx := newConsoleFixture(t) |
| 37 | now := time.Now() |
| 38 | if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| 39 | t.Fatal(err) |
| 40 | } |
| 41 | // A MEMBER, not an owner: cycling your own app's processes is a member |
| 42 | // action, like promote below stable. |
| 43 | if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil { |
| 44 | t.Fatal(err) |
| 45 | } |
| 46 | return fx, cliToken(t, fx, "alice@keymail.dev", code) |
| 47 | } |
| 48 | |
| 49 | // TestAPIRestartTouchesTheGenerationAndAnnouncesIt is the whole door: the |
| 50 | // object a box watches moves, the serial says "look now", and the answer names |
| 51 | // the canonical account (the caller may have typed a name). |
| 52 | func TestAPIRestartTouchesTheGenerationAndAnnouncesIt(t *testing.T) { |
| 53 | fx, token := restartFixture(t, "code-restart-roundtrip") |
| 54 | if got := restartGenEtag(t, fx, "titogo"); got != "" { |
| 55 | t.Fatalf("restart generation exists before any request: %q", got) |
| 56 | } |
| 57 | |
| 58 | status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", token, "") |
| 59 | if status != http.StatusOK { |
| 60 | t.Fatalf("restart = %d %s, want 200", status, out) |
| 61 | } |
| 62 | var res struct { |
| 63 | Requested bool `json:"requested"` |
| 64 | Account string `json:"account"` |
| 65 | App string `json:"app"` |
| 66 | } |
| 67 | if err := json.Unmarshal(out, &res); err != nil { |
| 68 | t.Fatalf("restart body %s: %v", out, err) |
| 69 | } |
| 70 | if !res.Requested || res.Account != "bab" || res.App != "titogo" { |
| 71 | t.Fatalf("restart body = %+v, want requested for bab/titogo", res) |
| 72 | } |
| 73 | first := restartGenEtag(t, fx, "titogo") |
| 74 | if first == "" { |
| 75 | t.Fatal("no restart generation in the bucket after a 200 — the boxes will never hear about it") |
| 76 | } |
| 77 | if got := serial(t, fx); got != 1 { |
| 78 | t.Fatalf("serial after a restart request = %d, want 1 (otherwise boxes wait out the hourly floor)", got) |
| 79 | } |
| 80 | |
| 81 | // A SECOND request must move the etag again: change detection is by etag, |
| 82 | // so a byte-identical rewrite would be a restart nobody performs. |
| 83 | status, out = postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", token, "") |
| 84 | if status != http.StatusOK { |
| 85 | t.Fatalf("second restart = %d %s", status, out) |
| 86 | } |
| 87 | if second := restartGenEtag(t, fx, "titogo"); second == first { |
| 88 | t.Fatalf("etag %q unchanged after a second request", second) |
| 89 | } |
| 90 | if got := serial(t, fx); got != 2 { |
| 91 | t.Fatalf("serial after two requests = %d, want 2", got) |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | // TestAPIRestartMemberGate: a non-member's refusal is byte-identical with an |
| 96 | // unknown account's and an unknown app's — the same gate, in the same place, |
| 97 | // as every other door here. Probing this one must not reveal that an app |
| 98 | // exists, let alone restart it. |
| 99 | func TestAPIRestartMemberGate(t *testing.T) { |
| 100 | fx, _ := restartFixture(t, "code-restart-gate-member") |
| 101 | eve := cliToken(t, fx, "eve@keymail.dev", "code-restart-gate-eve") |
| 102 | |
| 103 | status, body := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", map[string]string{}, eve) |
| 104 | if status != http.StatusNotFound { |
| 105 | t.Fatalf("restart as non-member = %d, want 404", status) |
| 106 | } |
| 107 | if body != "404 page not found\n" { |
| 108 | t.Fatalf("restart as non-member body = %q, want the stdlib http.NotFound output", body) |
| 109 | } |
| 110 | // An unknown app in an account eve IS a member of would have to answer the |
| 111 | // same; here the account itself is not hers, and neither answer moved |
| 112 | // anything. |
| 113 | if got := restartGenEtag(t, fx, "titogo"); got != "" { |
| 114 | t.Fatal("a refused request touched the restart generation") |
| 115 | } |
| 116 | if got := serial(t, fx); got != 0 { |
| 117 | t.Fatalf("serial after a refused request = %d, want 0", got) |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | // TestAPIRestartNoTokenIs401: the token gate is first, before any name is |
| 122 | // resolved. |
| 123 | func TestAPIRestartNoTokenIs401(t *testing.T) { |
| 124 | fx, _ := restartFixture(t, "code-restart-notoken") |
| 125 | if status, _ := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", "", ""); status != http.StatusUnauthorized { |
| 126 | t.Fatalf("restart with no token = %d, want 401", status) |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | // TestAPIRestartIsRateLimited: this door's whole effect is to stop and start |
| 131 | // processes, so the budget is what stands between a token and a restart loop |
| 132 | // aimed at a live app. Keyed on the address, like every other write here. |
| 133 | func TestAPIRestartIsRateLimited(t *testing.T) { |
| 134 | fx, token := restartFixture(t, "code-restart-limit") |
| 135 | fx.srv.cli.write = newRateLimiter(1, time.Minute) |
| 136 | |
| 137 | if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", token, ""); status != http.StatusOK { |
| 138 | t.Fatalf("first restart = %d %s, want 200", status, out) |
| 139 | } |
| 140 | status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", token, "") |
| 141 | if status != http.StatusTooManyRequests { |
| 142 | t.Fatalf("second restart on an exhausted budget = %d %s, want 429", status, out) |
| 143 | } |
| 144 | // A second TOKEN for the same person shares the budget: the principal is |
| 145 | // the person, not the terminal. |
| 146 | other := cliToken(t, fx, "alice@keymail.dev", "code-restart-limit-2") |
| 147 | if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", other, ""); status != http.StatusTooManyRequests { |
| 148 | t.Fatalf("restart from a second token = %d %s, want 429", status, out) |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | // TestAPIRestartRefusesATrashedApp: the live-apps resolver is the gate, so an |
| 153 | // app in the trash answers 404 like anything else that isn't there — there are |
| 154 | // no processes to cycle, and this door must not become the way to ask whether |
| 155 | // somebody's app was deleted. |
| 156 | func TestAPIRestartRefusesATrashedApp(t *testing.T) { |
| 157 | fx, token := restartFixture(t, "code-restart-trashed") |
| 158 | if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, ""); status != http.StatusOK { |
| 159 | t.Fatalf("trash = %d %s", status, out) |
| 160 | } |
| 161 | if status, body := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", token, ""); status != http.StatusNotFound { |
| 162 | t.Fatalf("restart of a trashed app = %d %s, want 404", status, body) |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | // TestAPIRestartWithoutABucketIs503: the request IS a bucket object. A console |
| 167 | // with nowhere to put it must say so rather than answer "requested" for |
| 168 | // something no box will ever see. |
| 169 | func TestAPIRestartWithoutABucketIs503(t *testing.T) { |
| 170 | fx, token := restartFixture(t, "code-restart-nobucket") |
| 171 | fx.srv.cfg.Releases = nil |
| 172 | |
| 173 | status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", token, "") |
| 174 | if status != http.StatusServiceUnavailable { |
| 175 | t.Fatalf("restart with no bucket = %d %s, want 503", status, out) |
| 176 | } |
| 177 | } |
| 178 | |