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
3import (
4 "context"
5 "fmt"
6 "html"
7 "html/template"
8 "net/http"
9 "net/url"
10 "os"
11 "regexp"
12 "slices"
13 "strings"
14 "testing"
15 "time"
16
17 "github.com/carlosframework/platform/internal/provision"
18 "github.com/carlosframework/platform/internal/release"
19 "github.com/carlosframework/platform/internal/status"
20 "github.com/carlosframework/platform/internal/updatewords"
21)
22
23// pageFx is zdd-6 Task 12's heldFixture (kass's web instance, on stable, with
24// box1 reporting it held: installed v2, serving v1) plus the store helpers the
25// instance page's Updates section tests use. Every helper writes the store
26// directly, bypassing the settings doors.
27type pageFx struct {
28 *consoleFixture
29 t *testing.T
30 // report is box1's update report for heldHost, written by boxesEnforce.
31 report *status.HostUpdates
32}
33
34func updatesPageFixture(t *testing.T) *pageFx {
35 t.Helper()
36 return &pageFx{consoleFixture: heldFixture(t), t: t}
37}
38
39// esc is a text as the instance page's html/template prints it.
40func esc(s string) string { return template.HTMLEscapeString(s) }
41
42// getPage GETs u, a full URL or a redirect's path.
43func (fx *pageFx) getPage(c *http.Client, u string) (int, string) {
44 fx.t.Helper()
45 if strings.HasPrefix(u, "/") {
46 u = fx.ts.URL + u
47 }
48 code, _, body := get(fx.t, c, u)
49 return code, body
50}
51
52// updatesSection is the instance page's Updates section, "" when none.
53func updatesSection(body string) string {
54 i := strings.Index(body, `<section class="updates">`)
55 if i < 0 {
56 return ""
57 }
58 j := strings.Index(body[i:], "</section>")
59 if j < 0 {
60 return body[i:]
61 }
62 return body[i : i+j]
63}
64
65var (
66 formInputTag = regexp.MustCompile(`<input [^>]*>`)
67 formInputAttr = regexp.MustCompile(`([a-z_]+)="([^"]*)"`)
68)
69
70// browserForm is what a browser submits from the Updates section's Save form
71// as rendered, untouched: every hidden, text and number input with its
72// value, and a checkbox only when it is checked. A test edits the result the
73// way a member edits the form.
74func browserForm(t *testing.T, sec string) url.Values {
75 t.Helper()
76 i := strings.Index(sec, `<form method="post"`)
77 if i < 0 {
78 t.Fatalf("no Save form in:\n%s", sec)
79 }
80 form := sec[i:]
81 form = form[:strings.Index(form, "</form>")]
82 out := url.Values{}
83 for _, tag := range formInputTag.FindAllString(form, -1) {
84 attrs := map[string]string{}
85 for _, m := range formInputAttr.FindAllStringSubmatch(tag, -1) {
86 attrs[m[1]] = html.UnescapeString(m[2])
87 }
88 if attrs["type"] == "checkbox" && !strings.Contains(tag, " checked") {
89 continue
90 }
91 out.Add(attrs["name"], attrs["value"])
92 }
93 return out
94}
95
96// boxesEnforce rewrites box1, the deployment's only box, keeping its held
97// rollout report and adding heldHost to its routes, so the box join has an
98// update view for the host. on says whether box1's binary applies update
99// settings, which also opens or closes the console's fleet gate.
100func (fx *pageFx) boxesEnforce(on bool) {
101 fx.t.Helper()
102 box := status.CollectBox(nil, status.Probes{MachineID: "box1"}, time.Now())
103 m := &status.BoxMetrics{
104 Rollout: &status.RolloutReport{Hosts: map[string]status.HostRollout{
105 heldHost: {Account: fx.sqid, App: "kass", Installed: "v2", Serving: "v1", Good: "v1", Awake: true, HeldBy: "v2", HeldEpoch: heldEpoch, HeldBook: heldBook, Reason: status.ReasonHealth},
106 }},
107 RouteVersions: map[string]string{heldHost: "v2"},
108 }
109 if on {
110 m.UpdateSettings = status.UpdateSettingsProtocol
111 }
112 if fx.report != nil {
113 m.Updates = map[string]status.HostUpdates{heldHost: *fx.report}
114 }
115 box.Metrics = m
116 if err := status.WriteBox(context.Background(), fx.rel, box); err != nil {
117 fx.t.Fatal(err)
118 }
119}
120
121// writeBox1 replaces box1's snapshot with m: no rollout report unless m
122// carries one.
123func (fx *pageFx) writeBox1(m *status.BoxMetrics) {
124 fx.t.Helper()
125 box := status.CollectBox(nil, status.Probes{MachineID: "box1"}, time.Now())
126 box.Metrics = m
127 if err := status.WriteBox(context.Background(), fx.rel, box); err != nil {
128 fx.t.Fatal(err)
129 }
130}
131
132func (fx *pageFx) record(host string) provision.Record {
133 fx.t.Helper()
134 rec, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "kass", host)
135 if err != nil {
136 fx.t.Fatal(err)
137 }
138 return *rec
139}
140
141// setRecordSettings is a read-modify-write of host's record that bypasses
142// the settings doors (and so their fleet gate).
143func (fx *pageFx) setRecordSettings(host string, edit func(*provision.Record)) {
144 fx.t.Helper()
145 ctx := context.Background()
146 rec, etag, err := provision.GetRecordETag(ctx, fx.rel, fx.sqid, "kass", host)
147 if err != nil {
148 fx.t.Fatal(err)
149 }
150 edit(rec)
151 if err := provision.UpdateRecord(ctx, fx.rel, *rec, etag); err != nil {
152 fx.t.Fatal(err)
153 }
154}
155
156func (fx *pageFx) setRecordUpdates(host, updates string) {
157 fx.setRecordSettings(host, func(r *provision.Record) { r.Updates = updates })
158}
159
160func (fx *pageFx) pin(host, build string) {
161 fx.setRecordSettings(host, func(r *provision.Record) { r.Pin = build })
162}
163
164func (fx *pageFx) setChannel(host, channel string) {
165 fx.setRecordSettings(host, func(r *provision.Record) { r.Channel = channel })
166}
167
168// makeNamedUnit gives host's record a systemd unit of its own, as plan 3's
169// TestUpdatesDoorsRefuseNamedUnitsAndOtherKinds does.
170func (fx *pageFx) makeNamedUnit(host string) {
171 fx.setRecordSettings(host, func(r *provision.Record) { r.Unit, r.Addr = "console.service", "127.0.0.1:8080" })
172}
173
174// grant writes kass's instances manifest with an update policy whose
175// instance_control is names.
176func (fx *pageFx) grant(names ...string) {
177 fx.t.Helper()
178 enableInstances(fx.t, fx.consoleFixture, "kass", provision.Manifest{Schema: 1, App: "kass", Domain: "kass.carlosapp.dev", Channel: "stable",
179 Updates: &provision.UpdatePolicy{InstanceControl: names}})
180}
181
182// ageSession leaves c's session with no proof of presence, as the step-up
183// tests find a fresh sign-in: the owner has to confirm with a passkey.
184func (fx *pageFx) ageSession(c *http.Client) {
185 fx.t.Helper()
186 if _, err := fx.st.db.Exec(`DELETE FROM step_ups WHERE session_hash = ?`, hashToken(sessionToken(fx.t, fx.consoleFixture, c))); err != nil {
187 fx.t.Fatal(err)
188 }
189}
190
191func TestUpdatesSectionShowsDefaults(t *testing.T) {
192 fx := updatesPageFixture(t)
193 c := signInPaul(t, fx.consoleFixture)
194 code, body := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost))
195 sec := updatesSection(body)
196 if code != http.StatusOK || !strings.Contains(sec, esc(updatewords.Text("settings.page.heading"))) ||
197 !strings.Contains(sec, esc(updatewords.Text("settings.page.update-now"))) {
198 t.Fatalf("page: %d\n%s", code, body)
199 }
200}
201
202func TestPageSaveGoesThroughTheGate(t *testing.T) {
203 fx := updatesPageFixture(t)
204 fx.boxesEnforce(true)
205 c := signInPaul(t, fx.consoleFixture)
206 manual := url.Values{"form": {"updates"}, "was_auto": {"1"}} // the page showed auto; the checkbox unticked
207 bad := url.Values{"csrf": {"wrong"}}
208 for k, v := range manual {
209 bad[k] = v
210 }
211 if code, _, _ := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates"), bad); code != http.StatusForbidden {
212 t.Fatalf("bad csrf = %d", code)
213 }
214 csrf := sessionToken(t, fx.consoleFixture, c)
215 with := func(v url.Values) url.Values {
216 out := url.Values{"csrf": {csrf}}
217 for k, x := range v {
218 out[k] = x
219 }
220 return out
221 }
222 code, _, body := postForm(t, c, fx.u("/apps/kass/instances/nope.kass.carlosapp.dev/updates"), with(manual))
223 if code != http.StatusNotFound || body != "404 page not found\n" {
224 t.Fatalf("unknown host = %d %q", code, body)
225 }
226 code, loc, _ := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates"), with(manual))
227 if code != http.StatusSeeOther || loc != fx.path("/apps/kass/instances/"+heldHost) || fx.record(heldHost).Updates != release.UpdatesManual {
228 t.Fatalf("save = %d -> %q, record %+v", code, loc, fx.record(heldHost))
229 }
230 if _, page := fx.getPage(c, loc); !strings.Contains(page, esc(updatewords.Text("settings.page.saved"))) {
231 t.Fatalf("no saved notice:\n%s", page)
232 }
233 // An enabling change the fleet gate refuses comes back as plan 3's
234 // reviewed refusal, verbatim, in the error flash; nothing is written.
235 fx.setRecordUpdates(heldHost, "")
236 fx.boxesEnforce(false)
237 code, loc, _ = postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates"), with(manual))
238 if code != http.StatusSeeOther || fx.record(heldHost).Updates != "" {
239 t.Fatalf("refused save = %d, record %+v", code, fx.record(heldHost))
240 }
241 refusal := fmt.Sprintf(updatewords.Text("settings.console.not-enforced"), 1)
242 if _, page := fx.getPage(c, loc); !strings.Contains(page, `<p class="banner error">`+esc(refusal)+`</p>`) {
243 t.Fatalf("refusal flash lacks %q:\n%s", refusal, page)
244 }
245}
246
247// Review Focus 5.
248func TestPageUpdateNowRefusedWhenPinned(t *testing.T) {
249 fx := updatesPageFixture(t)
250 fx.pin(heldHost, "v1")
251 c := signInPaul(t, fx.consoleFixture)
252 csrf := sessionToken(t, fx.consoleFixture, c)
253 code, loc, _ := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates/install"), url.Values{"csrf": {csrf}})
254 if code != http.StatusSeeOther {
255 t.Fatalf("Update now = %d", code)
256 }
257 _, body := fx.getPage(c, loc)
258 refusal := fmt.Sprintf(updatewords.Text("settings.console.pinned"), heldHost, "v1")
259 if !strings.Contains(body, `<p class="banner error">`+esc(refusal)+`</p>`) || fx.record(heldHost).InstallRequest != nil {
260 t.Fatalf("pinned Update now: %s / %+v", body, fx.record(heldHost).InstallRequest)
261 }
262}
263
264func TestPageUpdateNowRequestsAnInstall(t *testing.T) {
265 fx := updatesPageFixture(t)
266 c := signInPaul(t, fx.consoleFixture)
267 csrf := sessionToken(t, fx.consoleFixture, c)
268 code, loc, _ := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates/install"), url.Values{"csrf": {csrf}})
269 rec := fx.record(heldHost)
270 if code != http.StatusSeeOther || rec.InstallRequest == nil || rec.InstallRequest.ID != 1 || rec.InstallRequest.FastTrack ||
271 rec.InstallRequest.By != "paul@keymail.dev" {
272 t.Fatalf("Update now = %d, record %+v", code, rec.InstallRequest)
273 }
274 if _, page := fx.getPage(c, loc); !strings.Contains(page, esc(updatewords.Text("settings.page.requested"))) {
275 t.Fatalf("no requested notice:\n%s", page)
276 }
277}
278
279// Review Focus 4, ruling R12: plan 3's approved named-unit words, no forms.
280func TestUpdatesSectionForANamedUnit(t *testing.T) {
281 fx := updatesPageFixture(t)
282 fx.grant("updates", "major", "pin")
283 fx.makeNamedUnit(heldHost)
284 c := signInPaul(t, fx.consoleFixture)
285 _, body := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost))
286 sec := updatesSection(body)
287 if !strings.Contains(sec, esc(fmt.Sprintf(updatewords.Text("settings.console.named-unit"), heldHost))) ||
288 strings.Contains(sec, "<form") || strings.Contains(sec, esc(updatewords.Text("settings.page.update-now"))) {
289 t.Fatalf("named unit section:\n%s", sec)
290 }
291}
292
293// Astra r1 (R21): a named unit's explanation does not depend on the record's
294// kind. A service-kind record run by its own unit shows the same words, and
295// no form.
296func TestUpdatesSectionForANamedUnitService(t *testing.T) {
297 fx := updatesPageFixture(t)
298 fx.grant("updates", "major", "pin")
299 fx.setRecordSettings(heldHost, func(r *provision.Record) { r.Kind, r.Unit, r.Addr = "service", "console.service", "127.0.0.1:8080" })
300 c := signInPaul(t, fx.consoleFixture)
301 code, body := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost))
302 sec := updatesSection(body)
303 if code != http.StatusOK || !strings.Contains(sec, esc(fmt.Sprintf(updatewords.Text("settings.console.named-unit"), heldHost))) ||
304 strings.Contains(sec, "<form") || strings.Contains(sec, esc(updatewords.Text("settings.page.update-now"))) {
305 t.Fatalf("named-unit service page = %d, section:\n%s", code, sec)
306 }
307}
308
309// R12: a record of another kind shows no Updates section at all.
310func TestUpdatesSectionIsAbsentForOtherKinds(t *testing.T) {
311 fx := updatesPageFixture(t)
312 fx.setRecordSettings(heldHost, func(r *provision.Record) { r.Kind, r.Addr = "service", "127.0.0.1:8080" })
313 c := signInPaul(t, fx.consoleFixture)
314 code, body := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost))
315 if code != http.StatusOK || updatesSection(body) != "" {
316 t.Fatalf("service route page = %d:\n%s", code, body)
317 }
318}
319
320func TestNotEnforcedIsShown(t *testing.T) {
321 fx := updatesPageFixture(t)
322 fx.boxesEnforce(false)
323 fx.setRecordUpdates(heldHost, "manual") // written directly: the door would refuse it
324 c := signInPaul(t, fx.consoleFixture)
325 _, body := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost))
326 if !strings.Contains(updatesSection(body), esc(updatewords.Text("settings.page.not-enforced"))) {
327 t.Fatalf("page:\n%s", body)
328 }
329}
330
331// R11: default settings on an old box are not worth a warning.
332func TestNotEnforcedIsHiddenForDefaultSettings(t *testing.T) {
333 fx := updatesPageFixture(t)
334 fx.boxesEnforce(false)
335 c := signInPaul(t, fx.consoleFixture)
336 _, body := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost))
337 if sec := updatesSection(body); sec == "" || strings.Contains(sec, esc(updatewords.Text("settings.page.not-enforced"))) {
338 t.Fatalf("default settings section:\n%s", sec)
339 }
340}
341
342func TestUpdatesSectionStatusLine(t *testing.T) {
343 fx := updatesPageFixture(t)
344 two := 2
345 fx.report = &status.HostUpdates{Desired: "v1", Reason: "major", Detail: "v3", Baking: "v4", Major: &two}
346 fx.boxesEnforce(true)
347 c := signInPaul(t, fx.consoleFixture)
348 _, body := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost))
349 reason := fmt.Sprintf(updatewords.Text("settings.box.reason.major"), 2, "v3") + "; " + fmt.Sprintf(updatewords.Text("settings.box.reason.baking"), "v4")
350 want := fmt.Sprintf(updatewords.Text("settings.page.status"), "v2", "v1", "v1", reason)
351 if !strings.Contains(updatesSection(body), esc(want)) {
352 t.Fatalf("status line %q not in:\n%s", want, updatesSection(body))
353 }
354}
355
356func TestReasonSentence(t *testing.T) {
357 adopt := updatewords.Text("settings.box.reason.adopt")
358 for _, tc := range []struct {
359 u updatesJoinView
360 want string
361 }{
362 {updatesJoinView{}, adopt},
363 {updatesJoinView{Reason: "adopt"}, adopt},
364 {updatesJoinView{Reason: "adopt", Baking: "v4"}, adopt + "; " + fmt.Sprintf(updatewords.Text("settings.box.reason.baking"), "v4")},
365 {updatesJoinView{Reason: "pin", Detail: "v1"}, fmt.Sprintf(updatewords.Text("settings.box.reason.pin"), "v1")},
366 {updatesJoinView{Reason: "request", Detail: "v5"}, fmt.Sprintf(updatewords.Text("settings.box.reason.request"), "v5")},
367 {updatesJoinView{Reason: "manual", Detail: "v2"}, fmt.Sprintf(updatewords.Text("settings.box.reason.manual"), "v2")},
368 {updatesJoinView{Reason: "failed", Detail: "v2"}, fmt.Sprintf(updatewords.Text("settings.box.reason.failed"), "v2")},
369 {updatesJoinView{Reason: "unknown"}, updatewords.Text("settings.box.reason.unknown")},
370 {updatesJoinView{Reason: "new"}, updatewords.Text("settings.box.reason.new")},
371 {updatesJoinView{Reason: "unauthorized", Baking: "v4"}, ""}, // a word with no draft is never looked up
372 } {
373 if got := reasonSentence(&tc.u); got != tc.want {
374 t.Errorf("reasonSentence(%+v) = %q, want %q", tc.u, got, tc.want)
375 }
376 }
377}
378
379func TestFastTrackNeedsFreshAuth(t *testing.T) {
380 fx := updatesPageFixture(t)
381 c := signInPaul(t, fx.consoleFixture) // an owner, with no recent proof of presence
382 fx.ageSession(c)
383 csrf := sessionToken(t, fx.consoleFixture, c)
384 code, loc, _ := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates/install"), url.Values{"csrf": {csrf}, "fast_track": {"1"}})
385 if code != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) || !strings.Contains(loc, "stepup") || fx.record(heldHost).InstallRequest != nil {
386 t.Fatalf("fast-track without fresh auth = %d %s", code, loc)
387 }
388 // The passkey round trip resumes the held request, fast track and all.
389 code, _, _ = completeStepUp(t, fx.consoleFixture, c, loc)
390 if rec := fx.record(heldHost); code != http.StatusSeeOther || rec.InstallRequest == nil || !rec.InstallRequest.FastTrack {
391 t.Fatalf("resumed fast track = %d, record %+v", code, rec.InstallRequest)
392 }
393}
394
395// A plain member asking to skip the safety delay gets the fast-track door's
396// answer, the byte-identical 404, before any passkey is asked for.
397func TestFastTrackIsAnOwnersFromThePage(t *testing.T) {
398 fx := updatesPageFixture(t)
399 if err := fx.st.AddMember(OpsAccountID, "kim@keymail.dev", RoleMember, time.Now()); err != nil {
400 t.Fatal(err)
401 }
402 fx.setUser("kim@keymail.dev")
403 c := newBrowser(t)
404 signUp(t, fx.consoleFixture, c, "code-kim")
405 csrf := sessionToken(t, fx.consoleFixture, c)
406 _, body := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost))
407 if sec := updatesSection(body); sec == "" || strings.Contains(sec, `name="fast_track"`) {
408 t.Fatalf("a member's section:\n%s", sec)
409 }
410 code, _, out := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates/install"), url.Values{"csrf": {csrf}, "fast_track": {"1"}})
411 if code != http.StatusNotFound || out != "404 page not found\n" || fx.record(heldHost).InstallRequest != nil {
412 t.Fatalf("member fast track = %d %q", code, out)
413 }
414 // Astra r1 (R19): with the write budget spent, the answer is still the
415 // owner 404, byte-identical to an unknown host's, not the rate-limit
416 // flash. The same budget refuses a plain Update now, so it is spent.
417 fx.srv.cli.write = newRateLimiter(1, time.Hour)
418 if _, loc, _ := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates"), url.Values{"csrf": {csrf}, "form": {"updates"}}); loc == "" {
419 t.Fatal("the no-op save that spends the budget did not redirect")
420 }
421 _, _, unknown := postForm(t, c, fx.u("/apps/kass/instances/nope.kass.carlosapp.dev/updates/install"), url.Values{"csrf": {csrf}, "fast_track": {"1"}})
422 code, _, out = postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates/install"), url.Values{"csrf": {csrf}, "fast_track": {"1"}})
423 if code != http.StatusNotFound || out != unknown || out != "404 page not found\n" || fx.record(heldHost).InstallRequest != nil {
424 t.Fatalf("member fast track on a spent budget = %d %q (unknown host %q), record %+v", code, out, unknown, fx.record(heldHost).InstallRequest)
425 }
426 code, loc, _ := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates/install"), url.Values{"csrf": {csrf}})
427 if _, page := fx.getPage(c, loc); code != http.StatusSeeOther || !strings.Contains(page, esc(updatewords.Text("settings.page.rate-limited"))) {
428 t.Fatalf("plain Update now on the spent budget = %d, not rate-limited:\n%s", code, updatesSection(page))
429 }
430}
431
432// Astra I3 #8: with the fleet gate closed, a member can still switch auto
433// back on, clear a major hold and clear a pin from the page, each posted
434// exactly as the browser posts the whole form.
435func TestClearingWorksFromThePageWhileTheGateIsClosed(t *testing.T) {
436 fx := updatesPageFixture(t)
437 fx.grant("updates", "major", "pin")
438 fx.boxesEnforce(false)
439 fx.setChannel(heldHost, "stable")
440 two := 2
441 fx.setRecordSettings(heldHost, func(r *provision.Record) { r.Updates, r.Major, r.Pin = "manual", &two, "v1" })
442 c := signInPaul(t, fx.consoleFixture)
443 _, page := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost))
444 sec := updatesSection(page)
445 if strings.Contains(sec, "disabled") {
446 t.Fatal("a settings control is disabled while the gate is closed")
447 }
448 for _, want := range []string{`name="auto"`, `name="was_auto" value="0"`, `name="major" value="2"`, `name="was_major" value="2"`,
449 `name="pin" value="v1"`, `name="was_pin" value="v1"`, esc(updatewords.Text("settings.page.refused-until-boxes"))} {
450 if !strings.Contains(sec, want) {
451 t.Fatalf("section lacks %q:\n%s", want, sec)
452 }
453 }
454 // save loads the page, makes one edit to its Save form and posts the
455 // whole form back, baselines and all, as the browser does.
456 save := func(edit func(url.Values)) {
457 t.Helper()
458 _, page := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost))
459 f := browserForm(t, updatesSection(page))
460 edit(f)
461 code, loc, _ := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates"), f)
462 if code != http.StatusSeeOther {
463 t.Fatalf("save = %d", code)
464 }
465 if _, page := fx.getPage(c, loc); strings.Contains(page, `class="banner error"`) {
466 t.Fatalf("save refused:\n%s", page)
467 }
468 }
469 save(func(f url.Values) { f.Set("pin", "") }) // clear the pin; still manual, major unchanged
470 if r := fx.record(heldHost); r.Pin != "" || r.Updates != "manual" || r.Major == nil || *r.Major != 2 {
471 t.Fatalf("clearing the pin: %+v", r)
472 }
473 save(func(f url.Values) { f.Set("major", "") }) // clear the major hold
474 if r := fx.record(heldHost); r.Major != nil || r.Updates != "manual" {
475 t.Fatalf("clearing major: %+v", r)
476 }
477 save(func(f url.Values) { f.Set("auto", "1") }) // auto back on
478 if r := fx.record(heldHost); r.Updates != "auto" {
479 t.Fatalf("switching auto on: %+v", r)
480 }
481}
482
483func TestAnOmittedCheckboxIsNotManual(t *testing.T) {
484 form := func(kv ...string) url.Values {
485 f := url.Values{"form": {"updates"}}
486 for i := 0; i < len(kv); i += 2 {
487 f.Add(kv[i], kv[i+1])
488 }
489 return f
490 }
491 if req := updatesFormRequest(url.Values{"csrf": {"x"}, "was_auto": {"1"}, "pin": {"v2"}, "was_pin": {""}}); len(req) != 0 {
492 t.Fatalf("a post without the form marker sent %v", req)
493 }
494 if req := updatesFormRequest(form("was_auto", "1", "auto", "1", "pin", "", "was_pin", "")); len(req) != 0 {
495 t.Fatalf("an unchanged form sent %v", req)
496 }
497 if req := updatesFormRequest(form()); len(req) != 0 {
498 t.Fatalf("a form with no checkbox shown sent %v", req)
499 }
500 if req := updatesFormRequest(form("auto", "1")); len(req) != 0 {
501 t.Fatalf("an auto with no checkbox shown sent %v", req)
502 }
503 if req := updatesFormRequest(form("was_auto", "yes")); len(req) != 0 {
504 t.Fatalf("a malformed baseline sent %v", req)
505 }
506 if req := updatesFormRequest(form("was_auto", "1")); req["updates"] != "manual" || len(req) != 1 {
507 t.Fatalf("unticking auto sent %v", req)
508 }
509 if req := updatesFormRequest(form("was_auto", "0", "auto", "1")); req["updates"] != "auto" || len(req) != 1 {
510 t.Fatalf("ticking auto sent %v", req)
511 }
512 if req := updatesFormRequest(form("was_auto", "0")); len(req) != 0 {
513 t.Fatalf("a box left unticked sent %v", req)
514 }
515 if req := updatesFormRequest(form("major", " 2 ", "was_major", "2", "pin", " v1 ", "was_pin", "v1")); len(req) != 0 {
516 t.Fatalf("an unchanged major and pin sent %v", req)
517 }
518 if req := updatesFormRequest(form("major", "02", "was_major", "2")); len(req) != 0 {
519 t.Fatalf("the same major written differently sent %v", req)
520 }
521 if req := updatesFormRequest(form("major", "3", "was_major", "2", "pin", "", "was_pin", "v1")); req["major"] != float64(3) || req["pin"] != "" || len(req) != 2 {
522 t.Fatalf("a new major and a cleared pin sent %v", req)
523 }
524 if req := updatesFormRequest(form("major", "", "was_major", "2")); req["major"] != "" || len(req) != 1 {
525 t.Fatalf("a cleared major sent %v", req)
526 }
527 if req := updatesFormRequest(form("major", "two", "was_major", "")); req["major"] != "two" {
528 t.Fatalf("a bad major sent %v", req)
529 }
530 // A control with no baseline was not rendered live: it sends nothing.
531 if req := updatesFormRequest(form("major", "3", "pin", "v2")); len(req) != 0 {
532 t.Fatalf("controls with no baseline sent %v", req)
533 }
534}
535
536// R10: a form without was_auto (the checkbox was not rendered, because the
537// app keeps updates) never reads the absent checkbox as manual.
538func TestAnUnshownCheckboxIsNeverReadAsManual(t *testing.T) {
539 fx := updatesPageFixture(t)
540 fx.boxesEnforce(true)
541 c := signInPaul(t, fx.consoleFixture)
542 csrf := sessionToken(t, fx.consoleFixture, c)
543 code, _, _ := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates"), url.Values{"csrf": {csrf}, "form": {"updates"}, "pin": {""}, "was_pin": {""}})
544 if r := fx.record(heldHost); code != http.StatusSeeOther || r.Updates != "" {
545 t.Fatalf("save = %d, record %+v", code, r)
546 }
547}
548
549// R10: with the default policy every setting is the app's: shown read-only,
550// no input for any of them, and no Save.
551func TestKeptSettingsAreReadOnly(t *testing.T) {
552 fx := updatesPageFixture(t)
553 two := 2
554 fx.setRecordSettings(heldHost, func(r *provision.Record) { r.Major, r.Pin = &two, "v1" })
555 c := signInPaul(t, fx.consoleFixture)
556 _, body := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost))
557 sec := updatesSection(body)
558 for _, bad := range []string{`name="auto"`, `name="was_auto"`, `name="major"`, `name="was_major"`, `name="pin"`, `name="was_pin"`, `name="form"`, esc(updatewords.Text("settings.page.save"))} {
559 if strings.Contains(sec, bad) {
560 t.Errorf("kept settings section has %q", bad)
561 }
562 }
563 for _, want := range []string{esc(updatewords.Text("settings.page.kept")), "<code>auto</code>", "<code>stable</code>", "<code>2</code>", "<code>v1</code>",
564 esc(updatewords.Text("settings.page.channel-hint")), esc(updatewords.Text("settings.page.update-now"))} {
565 if !strings.Contains(sec, want) {
566 t.Errorf("kept settings section lacks %q", want)
567 }
568 }
569 if t.Failed() {
570 t.Logf("section:\n%s", sec)
571 }
572}
573
574// staleSave posts f to the Save door and fails on anything but a clean save.
575func (fx *pageFx) staleSave(c *http.Client, f url.Values) {
576 fx.t.Helper()
577 code, loc, _ := postForm(fx.t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates"), f)
578 if code != http.StatusSeeOther {
579 fx.t.Fatalf("save = %d", code)
580 }
581 if _, page := fx.getPage(c, loc); !strings.Contains(page, esc(updatewords.Text("settings.page.saved"))) {
582 fx.t.Fatalf("save not saved:\n%s", page)
583 }
584}
585
586// updatesAudit is every instance-updates activity line for kass.
587func (fx *pageFx) updatesAudit() []string {
588 fx.t.Helper()
589 acts, err := fx.st.Events(OpsAccountID, "kass", 50)
590 if err != nil {
591 fx.t.Fatal(err)
592 }
593 var out []string
594 for _, a := range acts {
595 if a.Action == "instance-updates" {
596 out = append(out, a.Detail)
597 }
598 }
599 return out
600}
601
602// Review round 1 (R15): the form sends what the member changed on the page
603// they saw, not what differs from the record at post time. Member A's page
604// shows the instance pinned to v1; member B clears the pin behind it; A
605// unticks auto and saves. The pin A never touched stays cleared.
606func TestAStalePageDoesNotUndoAnotherMembersChange(t *testing.T) {
607 fx := updatesPageFixture(t)
608 fx.grant("updates", "major", "pin")
609 fx.boxesEnforce(true)
610 fx.pin(heldHost, "v1")
611 c := signInPaul(t, fx.consoleFixture)
612 _, body := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost))
613 f := browserForm(t, updatesSection(body))
614 if f.Get("pin") != "v1" || f.Get("auto") != "1" {
615 t.Fatalf("rendered form %v", f)
616 }
617 fx.pin(heldHost, "") // member B, through the CLI, after A's page loaded
618 f.Del("auto") // A unticks auto
619 fx.staleSave(c, f)
620 if r := fx.record(heldHost); r.Updates != release.UpdatesManual || r.Pin != "" {
621 t.Fatalf("record after a stale save: updates %q pin %q", r.Updates, r.Pin)
622 }
623 want := []string{fmt.Sprintf(updatewords.Text("settings.console.audit-set"), heldHost, "updates manual")}
624 if got := fx.updatesAudit(); !slices.Equal(got, want) {
625 t.Fatalf("audit = %q, want %q", got, want)
626 }
627}
628
629// The auto variant: A's page shows the instance manual and pinned; B switches
630// it to auto behind it; A only clears the pin. The unticked box A left alone
631// does not switch the instance back to manual.
632func TestAStalePageDoesNotUndoAnotherMembersSwitchToAuto(t *testing.T) {
633 fx := updatesPageFixture(t)
634 fx.grant("updates", "major", "pin")
635 fx.boxesEnforce(true)
636 fx.setRecordSettings(heldHost, func(r *provision.Record) { r.Updates, r.Pin = release.UpdatesManual, "v1" })
637 c := signInPaul(t, fx.consoleFixture)
638 _, body := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost))
639 f := browserForm(t, updatesSection(body))
640 if f.Get("pin") != "v1" || f.Has("auto") {
641 t.Fatalf("rendered form %v", f)
642 }
643 fx.setRecordUpdates(heldHost, release.UpdatesAuto) // member B
644 f.Set("pin", "") // A clears the pin
645 fx.staleSave(c, f)
646 if r := fx.record(heldHost); r.Updates != release.UpdatesAuto || r.Pin != "" {
647 t.Fatalf("record after a stale save: updates %q pin %q", r.Updates, r.Pin)
648 }
649 want := []string{fmt.Sprintf(updatewords.Text("settings.console.audit-set"), heldHost, "pin cleared")}
650 if got := fx.updatesAudit(); !slices.Equal(got, want) {
651 t.Fatalf("audit = %q, want %q", got, want)
652 }
653}
654
655// Review round 1 (R16): both forms spend the shared write budget, and a
656// refusal is the page's reviewed words, not the API's.
657func TestPageSaveAndUpdateNowAreRateLimited(t *testing.T) {
658 fx := updatesPageFixture(t)
659 fx.srv.cli.write = newRateLimiter(1, time.Minute)
660 c := signInPaul(t, fx.consoleFixture)
661 csrf := sessionToken(t, fx.consoleFixture, c)
662 limited := `<p class="banner error">` + esc(updatewords.Text("settings.page.rate-limited")) + `</p>`
663 save := url.Values{"csrf": {csrf}, "form": {"updates"}}
664 _, loc, _ := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates"), save)
665 if _, page := fx.getPage(c, loc); !strings.Contains(page, esc(updatewords.Text("settings.page.saved"))) {
666 t.Fatalf("first save:\n%s", page)
667 }
668 _, loc, _ = postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates"), save)
669 if _, page := fx.getPage(c, loc); !strings.Contains(page, limited) {
670 t.Fatalf("second save lacks %q:\n%s", limited, page)
671 }
672 _, loc, _ = postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates/install"), url.Values{"csrf": {csrf}})
673 if _, page := fx.getPage(c, loc); !strings.Contains(page, limited) || fx.record(heldHost).InstallRequest != nil {
674 t.Fatalf("Update now on a spent budget lacks %q or wrote %+v:\n%s", limited, fx.record(heldHost).InstallRequest, page)
675 }
676}
677
678// TestUpdatesSectionPreview writes the instance page, Updates section and
679// all, to $UPDATEWORDS_PREVIEW for Paul's copy review: an owner's view of a
680// pinned instance on stable whose app grants updates, major and pin, with a
681// box report naming the build it wants and the fleet gate closed.
682func TestUpdatesSectionPreview(t *testing.T) {
683 out := os.Getenv("UPDATEWORDS_PREVIEW")
684 if out == "" {
685 t.Skip("UPDATEWORDS_PREVIEW is not set")
686 }
687 fx := updatesPageFixture(t)
688 fx.grant("updates", "major", "pin")
689 two := 2
690 fx.setRecordSettings(heldHost, func(r *provision.Record) { r.Channel, r.Major, r.Pin = "stable", &two, "v1" })
691 fx.report = &status.HostUpdates{Desired: "v1", Reason: "pin", Detail: "v1"}
692 fx.boxesEnforce(true)
693 seedEdgesRecord(t, fx.consoleFixture, "uk-1") // a box with no heartbeat closes the gate
694 c := signInPaul(t, fx.consoleFixture)
695 code, body := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost))
696 if code != http.StatusOK || !strings.Contains(updatesSection(body), esc(updatewords.Text("settings.page.refused-until-boxes"))) {
697 t.Fatalf("preview page = %d:\n%s", code, updatesSection(body))
698 }
699 if err := os.WriteFile(out, []byte(body), 0o644); err != nil {
700 t.Fatal(err)
701 }
702 t.Logf("wrote %s", out)
703}
704
705// Astra r1 (R18), through the instance page: box1 is a platform box with no
706// rollout report (CARLOS_SWAP off) whose update report for heldHost is still
707// the previous tenant's. kass's page shows no status line, no reason and no
708// build from it. An entry naming kass is kass's; one naming anyone else is
709// never shown.
710func TestAReassignedHostShowsNoPreviousTenantsReportOnThePage(t *testing.T) {
711 fx := updatesPageFixture(t)
712 c := signInPaul(t, fx.consoleFixture)
713 statusPrefix := esc(strings.SplitN(updatewords.Text("settings.page.status"), "%s", 2)[0])
714 box := func(tenant [2]string) *status.BoxMetrics {
715 return &status.BoxMetrics{
716 UpdateSettings: status.UpdateSettingsProtocol,
717 RouteVersions: map[string]string{heldHost: "v2"},
718 Updates: map[string]status.HostUpdates{heldHost: {Desired: "v-prev-desired", Reason: "pin", Detail: "v-prev-pin", Baking: "v-prev-baking",
719 Account: tenant[0], App: tenant[1]}},
720 }
721 }
722 for _, tenant := range [][2]string{{"", ""}, {"cdf", "kass"}, {fx.sqid, "blog"}} {
723 fx.writeBox1(box(tenant))
724 _, body := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost))
725 sec := updatesSection(body)
726 if sec == "" {
727 t.Fatalf("tenant %v: no Updates section:\n%s", tenant, body)
728 }
729 for _, leak := range []string{"v-prev-desired", "v-prev-pin", "v-prev-baking", statusPrefix} {
730 if strings.Contains(body, leak) {
731 t.Errorf("tenant %v: the page shows %q:\n%s", tenant, leak, sec)
732 }
733 }
734 }
735 fx.writeBox1(box([2]string{fx.sqid, "kass"}))
736 _, body := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost))
737 reason := fmt.Sprintf(updatewords.Text("settings.box.reason.pin"), "v-prev-pin") + "; " + fmt.Sprintf(updatewords.Text("settings.box.reason.baking"), "v-prev-baking")
738 if want := esc(fmt.Sprintf(updatewords.Text("settings.page.status"), "v2", updatewords.Text("settings.cli.none"), "v-prev-desired", reason)); !strings.Contains(updatesSection(body), want) {
739 t.Fatalf("kass's own attributed report: status line %q not in:\n%s", want, updatesSection(body))
740 }
741}
742
743// R18: with no tenant to attribute the report to, an old box still says the
744// settings are not enforced; that is the box's, not the instance's.
745func TestATenantlessOldBoxStillSaysNotEnforced(t *testing.T) {
746 fx := updatesPageFixture(t)
747 fx.writeBox1(&status.BoxMetrics{RouteVersions: map[string]string{heldHost: "v2"},
748 Updates: map[string]status.HostUpdates{heldHost: {Desired: "v-prev-desired", Reason: "manual", Detail: "v-prev-desired"}}})
749 fx.setRecordUpdates(heldHost, "manual")
750 c := signInPaul(t, fx.consoleFixture)
751 _, body := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost))
752 sec := updatesSection(body)
753 if !strings.Contains(sec, esc(updatewords.Text("settings.page.not-enforced"))) || strings.Contains(body, "v-prev-desired") {
754 t.Fatalf("section:\n%s", sec)
755 }
756}
757
758// Astra r1 (R20): the major box is the instance's own override, so it is
759// empty while the app's default holds the instance; the note beside it says
760// the app holds it and at which major. Clearing an override back to the
761// app's default works with the fleet gate closed, and the note comes back.
762func TestAnInheritedMajorHoldIsShownBesideTheBox(t *testing.T) {
763 fx := updatesPageFixture(t)
764 two, three := 2, 3
765 enableInstances(t, fx.consoleFixture, "kass", provision.Manifest{Schema: 1, App: "kass", Domain: "kass.carlosapp.dev", Channel: "stable",
766 Updates: &provision.UpdatePolicy{InstanceControl: []string{"updates", "major", "pin"}, DefaultMajor: &two}})
767 fx.boxesEnforce(false) // the fleet gate is closed
768 fx.setChannel(heldHost, "stable")
769 note := esc(fmt.Sprintf(updatewords.Text("settings.page.major-inherited"), 2))
770 c := signInPaul(t, fx.consoleFixture)
771 section := func() string {
772 t.Helper()
773 _, body := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost))
774 return updatesSection(body)
775 }
776 if sec := section(); !strings.Contains(sec, note) || !strings.Contains(sec, `name="major" value=""`) || !strings.Contains(sec, `name="was_major" value=""`) {
777 t.Fatalf("inherited hold: section lacks the note %q or an empty override:\n%s", note, sec)
778 }
779 fx.setRecordSettings(heldHost, func(r *provision.Record) { r.Major = &three })
780 sec := section()
781 if strings.Contains(sec, note) || !strings.Contains(sec, `name="major" value="3"`) || !strings.Contains(sec, `name="was_major" value="3"`) {
782 t.Fatalf("explicit hold: the note shows or the override is missing:\n%s", sec)
783 }
784 f := browserForm(t, sec)
785 f.Set("major", "") // back to the app's default
786 code, loc, _ := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates"), f)
787 if _, page := fx.getPage(c, loc); code != http.StatusSeeOther || !strings.Contains(page, esc(updatewords.Text("settings.page.saved"))) {
788 t.Fatalf("clearing the override = %d:\n%s", code, page)
789 }
790 if r := fx.record(heldHost); r.Major != nil {
791 t.Fatalf("the override survived: %+v", r)
792 }
793 if sec := section(); !strings.Contains(sec, note) || !strings.Contains(sec, `name="major" value=""`) {
794 t.Fatalf("after clearing: section lacks the note:\n%s", sec)
795 }
796 // Off stable no major applies, so there is no box and no note.
797 fx.setChannel(heldHost, "beta")
798 if sec := section(); strings.Contains(sec, note) || strings.Contains(sec, `name="major"`) {
799 t.Fatalf("off stable:\n%s", sec)
800 }
801}
802