| new file mode 100644 |
| index 000000000..b099d58ba |
| --- /dev/null |
| +++ b/internal/console/apirestart_test.go |
| @@ -0,0 +1,177 @@ |
| +package console |
| + |
| +// The `carlos restart` door (issue #113). What is pinned here, in the order it |
| +// would hurt if lost: the request actually lands in the bucket AND is |
| +// announced (a request nobody hears is a restart that never happens); a |
| +// stranger learns nothing from probing it; and the budget that keeps it from |
| +// becoming a restart loop is spent per person. |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "net/http" |
| + "os" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// restartGenEtag is what every box watches: "" when nobody has ever asked. |
| +func restartGenEtag(t *testing.T, fx *consoleFixture, app string) string { |
| + t.Helper() |
| + _, etag, err := fx.rel.Get(context.Background(), release.RestartGenKey(release.OpsAccount, app)) |
| + if err != nil { |
| + if errors.Is(err, os.ErrNotExist) { |
| + return "" |
| + } |
| + t.Fatalf("read restart generation: %v", err) |
| + } |
| + return etag |
| +} |
| + |
| +func restartFixture(t *testing.T, code string) (*consoleFixture, string) { |
| + t.Helper() |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + // A MEMBER, not an owner: cycling your own app's processes is a member |
| + // action, like promote below stable. |
| + if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + return fx, cliToken(t, fx, "alice@keymail.dev", code) |
| +} |
| + |
| +// TestAPIRestartTouchesTheGenerationAndAnnouncesIt is the whole door: the |
| +// object a box watches moves, the serial says "look now", and the answer names |
| +// the canonical account (the caller may have typed a name). |
| +func TestAPIRestartTouchesTheGenerationAndAnnouncesIt(t *testing.T) { |
| + fx, token := restartFixture(t, "code-restart-roundtrip") |
| + if got := restartGenEtag(t, fx, "titogo"); got != "" { |
| + t.Fatalf("restart generation exists before any request: %q", got) |
| + } |
| + |
| + status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", token, "") |
| + if status != http.StatusOK { |
| + t.Fatalf("restart = %d %s, want 200", status, out) |
| + } |
| + var res struct { |
| + Requested bool `json:"requested"` |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + } |
| + if err := json.Unmarshal(out, &res); err != nil { |
| + t.Fatalf("restart body %s: %v", out, err) |
| + } |
| + if !res.Requested || res.Account != "bab" || res.App != "titogo" { |
| + t.Fatalf("restart body = %+v, want requested for bab/titogo", res) |
| + } |
| + first := restartGenEtag(t, fx, "titogo") |
| + if first == "" { |
| + t.Fatal("no restart generation in the bucket after a 200 — the boxes will never hear about it") |
| + } |
| + if got := serial(t, fx); got != 1 { |
| + t.Fatalf("serial after a restart request = %d, want 1 (otherwise boxes wait out the hourly floor)", got) |
| + } |
| + |
| + // A SECOND request must move the etag again: change detection is by etag, |
| + // so a byte-identical rewrite would be a restart nobody performs. |
| + status, out = postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", token, "") |
| + if status != http.StatusOK { |
| + t.Fatalf("second restart = %d %s", status, out) |
| + } |
| + if second := restartGenEtag(t, fx, "titogo"); second == first { |
| + t.Fatalf("etag %q unchanged after a second request", second) |
| + } |
| + if got := serial(t, fx); got != 2 { |
| + t.Fatalf("serial after two requests = %d, want 2", got) |
| + } |
| +} |
| + |
| +// TestAPIRestartMemberGate: a non-member's refusal is byte-identical with an |
| +// unknown account's and an unknown app's — the same gate, in the same place, |
| +// as every other door here. Probing this one must not reveal that an app |
| +// exists, let alone restart it. |
| +func TestAPIRestartMemberGate(t *testing.T) { |
| + fx, _ := restartFixture(t, "code-restart-gate-member") |
| + eve := cliToken(t, fx, "eve@keymail.dev", "code-restart-gate-eve") |
| + |
| + status, body := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", map[string]string{}, eve) |
| + if status != http.StatusNotFound { |
| + t.Fatalf("restart as non-member = %d, want 404", status) |
| + } |
| + if body != "404 page not found\n" { |
| + t.Fatalf("restart as non-member body = %q, want the stdlib http.NotFound output", body) |
| + } |
| + // An unknown app in an account eve IS a member of would have to answer the |
| + // same; here the account itself is not hers, and neither answer moved |
| + // anything. |
| + if got := restartGenEtag(t, fx, "titogo"); got != "" { |
| + t.Fatal("a refused request touched the restart generation") |
| + } |
| + if got := serial(t, fx); got != 0 { |
| + t.Fatalf("serial after a refused request = %d, want 0", got) |
| + } |
| +} |
| + |
| +// TestAPIRestartNoTokenIs401: the token gate is first, before any name is |
| +// resolved. |
| +func TestAPIRestartNoTokenIs401(t *testing.T) { |
| + fx, _ := restartFixture(t, "code-restart-notoken") |
| + if status, _ := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", "", ""); status != http.StatusUnauthorized { |
| + t.Fatalf("restart with no token = %d, want 401", status) |
| + } |
| +} |
| + |
| +// TestAPIRestartIsRateLimited: this door's whole effect is to stop and start |
| +// processes, so the budget is what stands between a token and a restart loop |
| +// aimed at a live app. Keyed on the address, like every other write here. |
| +func TestAPIRestartIsRateLimited(t *testing.T) { |
| + fx, token := restartFixture(t, "code-restart-limit") |
| + fx.srv.cli.write = newRateLimiter(1, time.Minute) |
| + |
| + if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", token, ""); status != http.StatusOK { |
| + t.Fatalf("first restart = %d %s, want 200", status, out) |
| + } |
| + status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", token, "") |
| + if status != http.StatusTooManyRequests { |
| + t.Fatalf("second restart on an exhausted budget = %d %s, want 429", status, out) |
| + } |
| + // A second TOKEN for the same person shares the budget: the principal is |
| + // the person, not the terminal. |
| + other := cliToken(t, fx, "alice@keymail.dev", "code-restart-limit-2") |
| + if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", other, ""); status != http.StatusTooManyRequests { |
| + t.Fatalf("restart from a second token = %d %s, want 429", status, out) |
| + } |
| +} |
| + |
| +// TestAPIRestartRefusesATrashedApp: the live-apps resolver is the gate, so an |
| +// app in the trash answers 404 like anything else that isn't there — there are |
| +// no processes to cycle, and this door must not become the way to ask whether |
| +// somebody's app was deleted. |
| +func TestAPIRestartRefusesATrashedApp(t *testing.T) { |
| + fx, token := restartFixture(t, "code-restart-trashed") |
| + if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, ""); status != http.StatusOK { |
| + t.Fatalf("trash = %d %s", status, out) |
| + } |
| + if status, body := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", token, ""); status != http.StatusNotFound { |
| + t.Fatalf("restart of a trashed app = %d %s, want 404", status, body) |
| + } |
| +} |
| + |
| +// TestAPIRestartWithoutABucketIs503: the request IS a bucket object. A console |
| +// with nowhere to put it must say so rather than answer "requested" for |
| +// something no box will ever see. |
| +func TestAPIRestartWithoutABucketIs503(t *testing.T) { |
| + fx, token := restartFixture(t, "code-restart-nobucket") |
| + fx.srv.cfg.Releases = nil |
| + |
| + status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", token, "") |
| + if status != http.StatusServiceUnavailable { |
| + t.Fatalf("restart with no bucket = %d %s, want 503", status, out) |
| + } |
| +} |