package console import ( "context" "fmt" "html" "net/http" "net/url" "strings" "testing" "time" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/rolloutwords" "github.com/carlosframework/platform/internal/status" ) const heldHost = "web.kass.carlosapp.dev" func heldFixture(t *testing.T) *consoleFixture { t.Helper() fx := newConsoleFixture(t) fx.addRoute(t, registry.Route{Host: heldHost, App: "kass", Account: fx.sqid, Kind: "instance", Status: "ready", Version: "v2", Channel: "stable"}) sibling(t, fx, "kass", heldHost, "stable") writeHeldBox(t, fx, heldHost, "kass", "v2") return fx } func TestInstancePageShowsInstalledServingAndTheHold(t *testing.T) { fx := heldFixture(t) c := signInPaul(t, fx) code, _, body := get(t, c, fx.u("/apps/kass/instances/"+heldHost)) if code != http.StatusOK { t.Fatalf("page = %d", code) } for _, want := range []string{ swapCopy.Installed, "v2", swapCopy.Serving, "v1", swapCopy.Hold, fmt.Sprintf(swapCopy.HoldText, "v2", rolloutwords.Reason(status.ReasonHealth), "v1"), fmt.Sprintf(swapCopy.PromoteRetry, "kass", "v2", "stable"), `action="/a/` + fx.sqid + `/apps/kass/instances/` + heldHost + `/retry"`, } { if !strings.Contains(body, want) { t.Errorf("page lacks %q", want) } } } func TestInstancePageWithoutAReportIsUnchanged(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) _, _, body := get(t, c, fx.u("/apps/kass/instances/kass.carlosapp.dev")) if !strings.Contains(body, instanceCopy.Observed) || strings.Contains(body, swapCopy.Installed) { t.Fatal("a host with no rollout report must show the page as before") } } // A box that could not arm its scheduler reports only the installed build: // the page keeps Installed, shows no serving build, no hold and no Retry. func TestInstancePageOfAnUnavailableBoxShowsNoHoldAndNoRetry(t *testing.T) { fx := newConsoleFixture(t) fx.addRoute(t, registry.Route{Host: heldHost, App: "kass", Account: fx.sqid, Kind: "instance", Status: "ready", Version: "v2", Channel: "stable"}) sibling(t, fx, "kass", heldHost, "stable") box := status.CollectBox(nil, status.Probes{MachineID: "box1"}, time.Now()) box.Metrics = &status.BoxMetrics{ RouteVersions: map[string]string{heldHost: "v2"}, Rollout: &status.RolloutReport{Unavailable: "no-cgroup-delegation"}, } if err := status.WriteBox(context.Background(), fx.rel, box); err != nil { t.Fatal(err) } c := signInPaul(t, fx) code, _, body := get(t, c, fx.u("/apps/kass/instances/"+heldHost)) if code != http.StatusOK { t.Fatalf("page = %d", code) } if !strings.Contains(body, swapCopy.Installed) || !strings.Contains(body, "v2") { t.Error("an unavailable box's page must keep the installed build") } for _, bad := range []string{swapCopy.Hold, swapCopy.RetryButton, "/retry\"", "carlos promote --app"} { if strings.Contains(body, bad) { t.Errorf("an unavailable box's page must not show %q", bad) } } } func TestBrowserRetryConfirmsThenWrites(t *testing.T) { fx := heldFixture(t) c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) endpoint := fx.u("/apps/kass/instances/" + heldHost + "/retry") code, _, body := postForm(t, c, endpoint, url.Values{"csrf": {csrf}}) title := strings.NewReplacer("{host}", heldHost, "{build}", "v2").Replace(swapCopy.RetryTitle) if code != http.StatusOK || !strings.Contains(body, title) { t.Fatalf("confirm = %d, lacks %q", code, title) } code, loc, _ := postGated(t, fx, c, endpoint, url.Values{"csrf": {csrf}, "confirmed": {"1"}}) if code != http.StatusSeeOther || loc != fx.path("/apps/kass/instances/"+heldHost) { t.Fatalf("retry = %d -> %q", code, loc) } r := lastScoped(t, fx, "kass") if r.Scope.Kind != release.RestartScopeRetry || r.Scope.Value != heldHost { t.Fatalf("stored = %+v", r) } g, err := release.VerifyHostRetry(fx.pub, r.Scope.Signed, fx.sqid, "kass", heldHost, time.Now()) if err != nil || g.Build != "v2" || g.Epoch != heldEpoch || g.Book != heldBook { t.Fatalf("signed grant = %+v, %v; want v2 at epoch %d of book %s", g, err, heldEpoch, heldBook) } } // The browser twin of TestAPIInstanceRetryWithNoKeyWritesNothing. func TestBrowserRetryWithNoKeyWritesNothing(t *testing.T) { fx := heldFixture(t) fx.srv.cfg.Key = nil c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) code, _, body := postGated(t, fx, c, fx.u("/apps/kass/instances/"+heldHost+"/retry"), url.Values{"csrf": {csrf}, "confirmed": {"1"}}) if code != http.StatusServiceUnavailable || !strings.Contains(body, browserRetryNoKey) { t.Fatalf("no key = %d, want 503 naming the missing key", code) } noRetryWritten(t, fx, "kass") } func TestBrowserRetryRefusals(t *testing.T) { fx := heldFixture(t) c := signInPaul(t, fx) if code, _, _ := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/retry"), url.Values{"csrf": {"wrong"}}); code != http.StatusForbidden { t.Fatalf("bad csrf = %d", code) } csrf := sessionToken(t, fx, c) code, _, body := postForm(t, c, fx.u("/apps/kass/instances/nope.kass.carlosapp.dev/retry"), url.Values{"csrf": {csrf}, "confirmed": {"1"}}) if code != http.StatusNotFound || body != "404 page not found\n" { t.Fatalf("unknown host = %d %q", code, body) } } // The browser twin of TestAPIInstanceRetryAsksTheBoxsReportFirst: the // refusal is flashed on the instance page, before any confirmation, and // nothing is written. func TestBrowserRetryAsksTheBoxsReportFirst(t *testing.T) { for _, tc := range []struct { name string retryV int epoch uint64 book string want string }{ {"an older box", 0, heldEpoch, heldBook, browserRetryUnsupported}, {"no book", status.HostRetryV, heldEpoch, "", browserRetryUnsupported}, {"book not saved", status.HostRetryV, 0, heldBook, browserRetryUnsaved}, } { t.Run(tc.name, func(t *testing.T) { fx := newConsoleFixture(t) fx.addRoute(t, registry.Route{Host: heldHost, App: "kass", Account: fx.sqid, Kind: "instance", Status: "ready", Version: "v2", Channel: "stable"}) sibling(t, fx, "kass", heldHost, "stable") writeRetryReport(t, fx, heldHost, "", tc.retryV, tc.epoch, tc.book) c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) endpoint := fx.u("/apps/kass/instances/" + heldHost + "/retry") for _, form := range []url.Values{{"csrf": {csrf}}, {"csrf": {csrf}, "confirmed": {"1"}}} { code, loc, body := postGated(t, fx, c, endpoint, form) if code != http.StatusSeeOther || loc != fx.path("/apps/kass/instances/"+heldHost) { t.Fatalf("retry = %d -> %q, want the flash's redirect", code, loc) } if page := flashAfter(t, fx, c, code, loc, body); !strings.Contains(page, html.EscapeString(tc.want)) { t.Fatalf("the page after the refusal lacks %q", tc.want) } } noRetryWritten(t, fx, "kass") }) } } const downHost = "api.kass.carlosapp.dev" // writeDeclinedBox publishes a box that declined the flag but honours its // failure book: heldHost failed, downHost was left down, both held by v2 at // the given epoch. Reason is empty, as on the real box (recon D ยง1). func writeDeclinedBox(t *testing.T, fx *consoleFixture, code string, epoch uint64) { t.Helper() hr := func(attempt string) status.HostRollout { return status.HostRollout{Account: fx.sqid, App: "kass", Installed: "v2", Good: "v1", Attempt: attempt, AttemptBuild: "v2", HeldBy: "v2", HeldEpoch: epoch, HeldBook: heldBook, RetryV: status.HostRetryV} } box := status.CollectBox(nil, status.Probes{MachineID: "box1"}, time.Now()) box.Metrics = &status.BoxMetrics{ RouteVersions: map[string]string{heldHost: "v2", downHost: "v2"}, Rollout: &status.RolloutReport{Unavailable: code, FailureBook: "honoured", Hosts: map[string]status.HostRollout{ heldHost: hr(status.AttemptFailed), downHost: hr(status.AttemptDown)}}, } if err := status.WriteBox(context.Background(), fx.rel, box); err != nil { t.Fatal(err) } } func declinedFixture(t *testing.T, code string, epoch uint64) *consoleFixture { t.Helper() fx := newConsoleFixture(t) for _, h := range []string{heldHost, downHost} { fx.addRoute(t, registry.Route{Host: h, App: "kass", Account: fx.sqid, Kind: "instance", Status: "ready", Version: "v2", Channel: "stable"}) sibling(t, fx, "kass", h, "stable") } writeDeclinedBox(t, fx, code, epoch) return fx } // A box that declined the flag still holds a failed build; its page says so // in words that are true for it (it will not start the build, not "stays // on"), and offers Retry, whose confirmation says no canary runs there. func TestInstancePageOfADeclinedBoxShowsTheHoldAndRetry(t *testing.T) { fx := declinedFixture(t, "host-claims-on", heldEpoch) c := signInPaul(t, fx) retry := func(h string) string { return `action="/a/` + fx.sqid + `/apps/kass/instances/` + h + `/retry"` } for _, tc := range []struct { host, hold string }{ {heldHost, fmt.Sprintf(swapCopy.HoldDeclinedText, "v2")}, {downHost, fmt.Sprintf(swapCopy.HoldDeclinedDownText, "v2")}, } { code, _, body := get(t, c, fx.u("/apps/kass/instances/"+tc.host)) if code != http.StatusOK { t.Fatalf("%s: page = %d", tc.host, code) } for _, want := range []string{swapCopy.Hold, html.EscapeString(tc.hold), fmt.Sprintf(swapCopy.PromoteRetry, "kass", "v2", "stable"), retry(tc.host)} { if !strings.Contains(body, want) { t.Errorf("%s: page lacks %q", tc.host, want) } } for _, bad := range []string{"it failed ()", "stays on", "stays on until", "hold mode"} { if strings.Contains(body, bad) { t.Errorf("%s: a declined box's page must not say %q", tc.host, bad) } } } // The confirmation line is the declined one: no canary runs there. csrf := sessionToken(t, fx, c) code, _, body := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/retry"), url.Values{"csrf": {csrf}}) line := strings.NewReplacer("{host}", heldHost, "{build}", "v2").Replace(swapCopy.RetryLineDeclined) canary := strings.NewReplacer("{host}", heldHost, "{build}", "v2").Replace(swapCopy.RetryLine) if code != http.StatusOK || !strings.Contains(body, html.EscapeString(line)) || strings.Contains(body, html.EscapeString(canary)) { t.Fatalf("confirm = %d; want the declined line %q and not the canary line", code, line) } code, _, _ = postGated(t, fx, c, fx.u("/apps/kass/instances/"+heldHost+"/retry"), url.Values{"csrf": {csrf}, "confirmed": {"1"}}) if code != http.StatusSeeOther { t.Fatalf("retry = %d", code) } if r := lastScoped(t, fx, "kass"); r.Scope.Kind != release.RestartScopeRetry || r.Scope.Value != heldHost { t.Fatalf("stored = %+v", r) } } // A declined host whose book is unsaved (epoch 0) shows the hold, which is // true, and no Retry form, which would only be refused. func TestInstancePageOfADeclinedHostAtEpochZeroShowsTheHoldAndNoForm(t *testing.T) { fx := declinedFixture(t, "host-claims-on", 0) c := signInPaul(t, fx) _, _, body := get(t, c, fx.u("/apps/kass/instances/"+heldHost)) if !strings.Contains(body, html.EscapeString(fmt.Sprintf(swapCopy.HoldDeclinedText, "v2"))) { t.Error("the hold row must show") } if strings.Contains(body, "/retry\"") || strings.Contains(body, ">"+swapCopy.RetryButton+"<") { t.Error("a host at epoch 0 must not offer Retry") } } // A refusing code reports no per-host state worth acting on, and an unknown // code is never retried: with a held host, still no hold row and no button. func TestInstancePageOfARefusingBoxWithAHeldHostShowsNoHoldAndNoRetry(t *testing.T) { for _, code := range []string{"no-deploy-store", "a-newer-code"} { t.Run(code, func(t *testing.T) { fx := declinedFixture(t, code, heldEpoch) c := signInPaul(t, fx) _, _, body := get(t, c, fx.u("/apps/kass/instances/"+heldHost)) for _, bad := range []string{swapCopy.Hold, "/retry\"", "carlos promote --app", html.EscapeString(fmt.Sprintf(swapCopy.HoldDeclinedText, "v2"))} { if strings.Contains(body, bad) { t.Errorf("a %s box's page must not show %q", code, bad) } } }) } } // A retry unsupported by an older box hides the form but keeps the hold. func TestInstancePageOfAnOlderBoxKeepsTheHoldAndHidesRetry(t *testing.T) { fx := newConsoleFixture(t) fx.addRoute(t, registry.Route{Host: heldHost, App: "kass", Account: fx.sqid, Kind: "instance", Status: "ready", Version: "v2", Channel: "stable"}) sibling(t, fx, "kass", heldHost, "stable") writeRetryReport(t, fx, heldHost, "", 0, heldEpoch, heldBook) c := signInPaul(t, fx) _, _, body := get(t, c, fx.u("/apps/kass/instances/"+heldHost)) if !strings.Contains(body, swapCopy.Hold) || !strings.Contains(body, "carlos promote --app") { t.Error("the hold row and the promote line must stay") } if strings.Contains(body, "/retry\"") { t.Error("a box that cannot take a retry must not be offered one") } } // A report with no book identity alone (retry_v and the epoch present) hides // the form, since the door would refuse it, but keeps the hold row. func TestInstancePageWithNoHeldBookHidesRetryAndKeepsTheHold(t *testing.T) { fx := newConsoleFixture(t) fx.addRoute(t, registry.Route{Host: heldHost, App: "kass", Account: fx.sqid, Kind: "instance", Status: "ready", Version: "v2", Channel: "stable"}) sibling(t, fx, "kass", heldHost, "stable") writeRetryReport(t, fx, heldHost, "", status.HostRetryV, heldEpoch, "") c := signInPaul(t, fx) _, _, body := get(t, c, fx.u("/apps/kass/instances/"+heldHost)) if !strings.Contains(body, swapCopy.Hold) || !strings.Contains(body, "carlos promote --app") { t.Error("the hold row and the promote line must stay") } if strings.Contains(body, "/retry\"") { t.Error("a report with no book identity must not be offered Retry") } }