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