| 1 | package console |
| 2 | |
| 3 | import ( |
| 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. |
| 27 | type 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 | |
| 34 | func 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. |
| 40 | func esc(s string) string { return template.HTMLEscapeString(s) } |
| 41 | |
| 42 | // getPage GETs u, a full URL or a redirect's path. |
| 43 | func (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. |
| 53 | func 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 | |
| 65 | var ( |
| 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. |
| 74 | func 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. |
| 100 | func (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. |
| 123 | func (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 | |
| 132 | func (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). |
| 143 | func (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 | |
| 156 | func (fx *pageFx) setRecordUpdates(host, updates string) { |
| 157 | fx.setRecordSettings(host, func(r *provision.Record) { r.Updates = updates }) |
| 158 | } |
| 159 | |
| 160 | func (fx *pageFx) pin(host, build string) { |
| 161 | fx.setRecordSettings(host, func(r *provision.Record) { r.Pin = build }) |
| 162 | } |
| 163 | |
| 164 | func (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. |
| 170 | func (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. |
| 176 | func (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. |
| 184 | func (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 | |
| 191 | func 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 | |
| 202 | func 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. |
| 248 | func 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 | |
| 264 | func 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. |
| 280 | func 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. |
| 296 | func 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. |
| 310 | func 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 | |
| 320 | func 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. |
| 332 | func 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 | |
| 342 | func 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 | |
| 356 | func 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 | |
| 379 | func 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. |
| 397 | func 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. |
| 435 | func 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 | |
| 483 | func 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. |
| 538 | func 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. |
| 551 | func 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. |
| 575 | func (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. |
| 587 | func (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. |
| 606 | func 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. |
| 632 | func 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. |
| 657 | func 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. |
| 682 | func 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. |
| 710 | func 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. |
| 745 | func 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. |
| 762 | func 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 | |