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) } }