carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package 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
9import (
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.
22func 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
34func 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).
52func 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.
99func 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.
123func 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.
133func 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.
156func 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.
169func 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