package main import ( "context" "flag" "fmt" "io" "net/http" "net/http/httptest" "os/exec" "reflect" "strings" "sync" "testing" "github.com/carlosframework/platform/internal/updatewords" ) // updatesConsole is a console for the instance update verbs: it answers the // settings door (GET and PUT), the install door and the channel door, and // records every request body and the order the doors were called in. type updatesConsole struct { mu sync.Mutex // getView is what the GET answers; putView is what the PUT answers (the // GET's, when empty). getView, putView string // putStatus/putBody make the PUT refuse; installStatus/installBody the // install door; channelStatus/channelBody the channel door. putStatus, installStatus, channelStatus int putBody, installBody, channelBody string puts, installs, channelPosts []string order []string } func (c *updatesConsole) session(t *testing.T) *apiSession { t.Helper() ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { c.mu.Lock() defer c.mu.Unlock() b, _ := io.ReadAll(r.Body) w.Header().Set("Content-Type", "application/json") answer := func(status int, body string) { if status != 0 { w.WriteHeader(status) } io.WriteString(w, body) } switch { case r.Method == http.MethodGet && r.URL.Path == "/api/cli/apps/bab/shop/instances/a.shop.example.com/updates": c.order = append(c.order, "get") answer(0, c.getView) case r.Method == http.MethodPut && r.URL.Path == "/api/cli/apps/bab/shop/instances/a.shop.example.com/updates": c.order = append(c.order, "updates") c.puts = append(c.puts, string(b)) if c.putStatus != 0 { answer(c.putStatus, c.putBody) return } view := c.putView if view == "" { view = c.getView } answer(0, view) case r.Method == http.MethodPost && r.URL.Path == "/api/cli/apps/bab/shop/instances/a.shop.example.com/updates/install": c.order = append(c.order, "install") c.installs = append(c.installs, string(b)) if c.installStatus != 0 { answer(c.installStatus, c.installBody) return } answer(0, `{"request":4}`) case r.Method == http.MethodPost && r.URL.Path == "/api/cli/apps/bab/shop/instances/a.shop.example.com/channel": c.order = append(c.order, "channel") c.channelPosts = append(c.channelPosts, string(b)) if c.channelStatus != 0 { answer(c.channelStatus, c.channelBody) return } answer(0, `{"host":"a.shop.example.com","channel":"beta","kind":"instance"}`) default: c.order = append(c.order, "unexpected "+r.Method+" "+r.URL.Path) w.WriteHeader(http.StatusNotFound) } })) t.Cleanup(ts.Close) return newTestSession(ts.URL) } func (c *updatesConsole) orderStr() string { return strings.Join(c.order, ",") } const ( testHost = "a.shop.example.com" // the record's own values (explicit) differ from the effective ones when // the app's policy fills a gap. viewAuto = `{"host":"a.shop.example.com","settings":{"updates":"auto","channel":"stable","major":null,"pin":""},"explicit":{"updates":"","channel":"stable","major":null,"pin":""},"enforced":true}` viewManual = `{"host":"a.shop.example.com","settings":{"updates":"manual","channel":"stable","major":null,"pin":""},"explicit":{"updates":"manual","channel":"stable","major":null,"pin":""},"enforced":true}` ) func setRun(t *testing.T, c *updatesConsole, req map[string]any, channel *string) (string, error) { t.Helper() out := captureStdout(t) err := instancesSetRun(context.Background(), c.session(t), apiAcct{Seg: "bab"}, "shop", testHost, req, channel) return out.String(), err } func TestInstancesSetSendsOnlyTheFlagsGiven(t *testing.T) { c := &updatesConsole{getView: viewAuto, putView: viewManual} out, err := setRun(t, c, map[string]any{"updates": "manual"}, nil) if err != nil { t.Fatal(err) } if len(c.puts) != 1 || c.puts[0] != `{"updates":"manual"}` { t.Fatalf("PUT bodies = %q", c.puts) } if len(c.channelPosts) != 0 { t.Fatalf("the channel door was called: %q", c.channelPosts) } want := fmt.Sprintf(updatewords.Text("settings.cli.set-done")+"\n", testHost, "manual", "stable", "none", "none") if out != want { t.Fatalf("output = %q, want %q", out, want) } } func TestInstancesSetRequestSendsOnlyWhatWasGiven(t *testing.T) { parse := func(args ...string) (*flag.FlagSet, *string, *string, *string) { fs := flag.NewFlagSet("t", flag.ContinueOnError) u := fs.String("updates", "", "") m := fs.String("major", "", "") p := fs.String("pin", "", "") if err := fs.Parse(args); err != nil { t.Fatal(err) } return fs, u, m, p } fs, u, m, p := parse("--updates", " manual ", "--major", "2", "--pin", "") req, err := instancesSetRequest(fs, *u, *m, *p) if err != nil || !reflect.DeepEqual(req, map[string]any{"updates": "manual", "major": 2, "pin": ""}) { t.Fatalf("req = %#v, %v", req, err) } fs, u, m, p = parse("--major", "") req, err = instancesSetRequest(fs, *u, *m, *p) if err != nil || !reflect.DeepEqual(req, map[string]any{"major": nil}) { t.Fatalf("req = %#v, %v", req, err) } fs, u, m, p = parse() if req, err = instancesSetRequest(fs, *u, *m, *p); err != nil || len(req) != 0 { t.Fatalf("no flags: req = %#v, %v", req, err) } for _, bad := range []string{"two", "-1", "1.5"} { fs, u, m, p = parse("--major", bad) if _, err := instancesSetRequest(fs, *u, *m, *p); err == nil || err.Error() != updatewords.Text("settings.cli.bad-major-flag") { t.Fatalf("--major %q: err = %v", bad, err) } } } // Review Focus 1: "nothing changed" is the record's own values not moving, // not the effective ones. func TestInstancesSetNoChange(t *testing.T) { c := &updatesConsole{getView: viewAuto} out, err := setRun(t, c, map[string]any{"pin": ""}, nil) if err != nil { t.Fatal(err) } if len(c.puts) != 1 || c.puts[0] != `{"pin":""}` { t.Fatalf("PUT bodies = %q", c.puts) } if want := fmt.Sprintf(updatewords.Text("settings.cli.set-unchanged")+"\n", testHost); out != want { t.Fatalf("output = %q, want %q", out, want) } } // A change in a field the effective view does not show (a record's updates // value that the app policy already supplies) is still a change. func TestInstancesSetExplicitChangeWithSameEffective(t *testing.T) { same := `{"host":"a.shop.example.com","settings":{"updates":"auto","channel":"stable","major":null,"pin":""},"explicit":{"updates":"auto","channel":"stable","major":null,"pin":""},"enforced":true}` c := &updatesConsole{getView: viewAuto, putView: same} out, err := setRun(t, c, map[string]any{"updates": "auto"}, nil) if err != nil { t.Fatal(err) } if strings.Contains(out, "nothing changed") { t.Fatalf("output = %q", out) } } func TestInstancesSetMajorComparesByValue(t *testing.T) { a := `{"host":"a.shop.example.com","settings":{"updates":"auto","channel":"stable","major":3,"pin":""},"explicit":{"updates":"","channel":"stable","major":3,"pin":""},"enforced":true}` c := &updatesConsole{getView: a} out, err := setRun(t, c, map[string]any{"major": 3}, nil) if err != nil { t.Fatal(err) } if !strings.Contains(out, "nothing changed") { t.Fatalf("a major set to what it was reads as a change: %q", out) } } // Review Focus 2: the settings go first, a gated channel move is not a // failure, and it does not undo the settings that were applied. func TestInstancesSetChannelProposedStillAppliesPin(t *testing.T) { pinned := `{"host":"a.shop.example.com","settings":{"updates":"auto","channel":"stable","major":null,"pin":"v3"},"explicit":{"updates":"","channel":"stable","major":null,"pin":"v3"},"enforced":true}` c := &updatesConsole{getView: viewAuto, putView: pinned, channelStatus: http.StatusAccepted, channelBody: `{"proposed":true,"needed":2,"console_url":"https://console.example/p/9"}`} beta := "beta" out, err := setRun(t, c, map[string]any{"pin": "v3"}, &beta) if err != nil { t.Fatalf("a proposed move is not a failure: %v", err) } if len(c.puts) != 1 || len(c.channelPosts) != 1 || c.orderStr() != "get,updates,channel" { t.Fatalf("puts %q, channel %q, order %s", c.puts, c.channelPosts, c.orderStr()) } if c.channelPosts[0] != `{"channel":"beta"}` { t.Fatalf("channel body = %q", c.channelPosts[0]) } if !strings.Contains(out, "is proposed and needs 2 confirmation(s) at https://console.example/p/9") { t.Fatalf("output:\n%s", out) } if !strings.Contains(out, "pin v3") { t.Fatalf("the settings line is missing:\n%s", out) } } // --channel alone is the channel door and nothing else. func TestInstancesSetChannelOnly(t *testing.T) { c := &updatesConsole{} beta := "beta" out, err := setRun(t, c, nil, &beta) if err != nil { t.Fatal(err) } if c.orderStr() != "channel" { t.Fatalf("order = %s", c.orderStr()) } if !strings.Contains(out, "moved a.shop.example.com to beta") { t.Fatalf("output:\n%s", out) } } // A refused settings write stops the run: the channel door is not tried. func TestInstancesSetPrintsTheConsolesRefusal(t *testing.T) { c := &updatesConsole{getView: viewAuto, putStatus: http.StatusConflict, putBody: `{"error":"v9 cannot be pinned: no promotion record."}`} beta := "beta" _, err := setRun(t, c, map[string]any{"pin": "v9"}, &beta) if err == nil || !strings.Contains(err.Error(), "v9 cannot be pinned: no promotion record.") { t.Fatalf("err = %v", err) } if len(c.channelPosts) != 0 { t.Fatalf("the channel door was called after a refused write: %q", c.channelPosts) } } // A refused channel move is the console's words too, after the settings line. func TestInstancesSetChannelRefusalAfterSettings(t *testing.T) { c := &updatesConsole{getView: viewAuto, putView: viewManual, channelStatus: http.StatusConflict, channelBody: `{"error":"no such channel"}`} beta := "beta" out, err := setRun(t, c, map[string]any{"updates": "manual"}, &beta) if err == nil || !strings.Contains(err.Error(), "no such channel") { t.Fatalf("err = %v", err) } if !strings.Contains(out, "updates manual") { t.Fatalf("the settings line is missing:\n%s", out) } } // R8: the "not every box applies" line is for settings that need enforcing. func TestInstancesSetNotEnforcedOnlyForNonDefault(t *testing.T) { notEnforced := func(view string) string { return strings.Replace(view, `"enforced":true`, `"enforced":false`, 1) } line := fmt.Sprintf(updatewords.Text("settings.cli.set-not-enforced"), testHost) cases := []struct { name string view string want bool }{ {"manual", notEnforced(viewManual), true}, {"auto and stable", notEnforced(viewAuto), false}, {"a major", strings.Replace(notEnforced(viewAuto), `"major":null`, `"major":2`, 1), true}, {"a pin", strings.Replace(notEnforced(viewAuto), `"pin":""`, `"pin":"v3"`, 1), true}, {"enforced", viewManual, false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { c := &updatesConsole{getView: viewAuto, putView: tc.view} out, err := setRun(t, c, map[string]any{"updates": "manual"}, nil) if err != nil { t.Fatal(err) } if got := strings.Contains(out, line); got != tc.want { t.Fatalf("not-enforced line present = %v, want %v:\n%s", got, tc.want, out) } }) } // It only follows settings that were sent. c := &updatesConsole{} beta := "beta" out, err := setRun(t, c, nil, &beta) if err != nil || strings.Contains(out, line) { t.Fatalf("channel only: err %v, output:\n%s", err, out) } } func TestInstancesUpdatePrintsTheRequest(t *testing.T) { c := &updatesConsole{} out := captureStdout(t) err := instancesUpdateRun(context.Background(), c.session(t), apiAcct{Seg: "bab"}, "shop", testHost, true) if err != nil { t.Fatal(err) } if len(c.installs) != 1 || c.installs[0] != `{"fast_track":true}` { t.Fatalf("installs %q", c.installs) } if want := fmt.Sprintf(updatewords.Text("settings.cli.update-done")+"\n", testHost, 4); out.String() != want { t.Fatalf("output = %q, want %q", out.String(), want) } c2 := &updatesConsole{} if err := instancesUpdateRun(context.Background(), c2.session(t), apiAcct{Seg: "bab"}, "shop", testHost, false); err != nil { t.Fatal(err) } if c2.installs[0] != `{"fast_track":false}` { t.Fatalf("installs %q", c2.installs) } } func TestInstancesUpdateRefusalsAreTheConsolesWords(t *testing.T) { for _, tc := range []struct { status int body string want string }{ {http.StatusConflict, `{"error":"a.shop.example.com is pinned to v3."}`, "a.shop.example.com is pinned to v3."}, {http.StatusForbidden, `{"error":"only an owner can fast track."}`, "only an owner can fast track."}, } { c := &updatesConsole{installStatus: tc.status, installBody: tc.body} out := captureStdout(t) err := instancesUpdateRun(context.Background(), c.session(t), apiAcct{Seg: "bab"}, "shop", testHost, false) if err == nil || !strings.Contains(err.Error(), tc.want) { t.Fatalf("status %d: err = %v", tc.status, err) } if out.Len() != 0 { t.Fatalf("status %d: printed %q", tc.status, out.String()) } } } // The verbs that end in log.Fatal run as the real binary. func TestInstancesSetAndUpdateRefuseDirectMode(t *testing.T) { store := t.TempDir() for _, tc := range []struct { verb string args []string }{ {"set", []string{"instances", "set", testHost, "--updates", "manual"}}, {"update", []string{"instances", "update", testHost}}, } { bin := buildCarlos(t) out, err := runCarlosEnv(t, bin, tc.args, "CARLOS_DEPLOYMENT_DIR="+store) if err == nil { t.Fatalf("%s exited 0 in direct mode:\n%s", tc.verb, out) } want := fmt.Sprintf(updatewords.Text("settings.cli.direct-mode"), tc.verb) if !strings.Contains(out, want) { t.Fatalf("%s: output\n%s\nwant %q", tc.verb, out, want) } } } func TestInstancesSetRefusesWhatItCannotSendBeforeAnyConsole(t *testing.T) { bin := buildCarlos(t) for _, args := range [][]string{ {"instances", "set", testHost}, // nothing to set {"instances", "set", testHost, "--channel", ""}, // a channel cannot be cleared {"instances", "set", "--updates", "manual"}, // no host {"instances", "update"}, // no host } { out, err := runCarlosEnv(t, bin, args) if err == nil { t.Fatalf("%v exited 0:\n%s", args, out) } usage := updatewords.Text("settings.cli.set-usage") if args[1] == "update" { usage = updatewords.Text("settings.cli.update-usage") } if !strings.Contains(out, usage) { t.Fatalf("%v: output\n%s\nwant the usage %q", args, out, usage) } } out, err := runCarlosEnv(t, bin, []string{"instances", "set", testHost, "--major", "two"}) if err == nil || !strings.Contains(out, updatewords.Text("settings.cli.bad-major-flag")) { t.Fatalf("bad major: err %v, output\n%s", err, out) } } func TestInstancesUsageNamesTheNewVerbs(t *testing.T) { for _, id := range []string{"settings.cli.instances-usage-lines", "settings.cli.instances-usage-para"} { if !strings.Contains(instancesRecordsUsage, updatewords.Text(id)) { t.Fatalf("the instances usage lacks %s", id) } } for _, line := range []string{"carlos instances retry --app \n", "does the same on every box.\n"} { if !strings.Contains(instancesRecordsUsage, line) { t.Fatalf("the instances usage lost %q", line) } } } // runCarlosEnv runs the built binary with the test environment (no console, // scratch credentials) plus extra variables. func runCarlosEnv(t *testing.T, bin string, args []string, extra ...string) (string, error) { t.Helper() cmd := exec.Command(bin, args...) cmd.Env = carlosTestEnv(t, extra...) cmd.Dir = t.TempDir() out, err := cmd.CombinedOutput() return string(out), err } // Astra r1 (R21): both verbs' help is the reviewed copy, with no em dash. // --account-id reads the shared draft (apps_swap.go keeps its pinned // literal). func TestInstancesSetAndUpdateHelpHasNoEmDash(t *testing.T) { bin := buildCarlos(t) for _, verb := range []string{"set", "update"} { out, _ := runCarlosEnv(t, bin, []string{"instances", verb, "-h"}) if !strings.Contains(out, updatewords.Text("settings.cli.flag-account-id")) { t.Errorf("instances %s -h lacks the --account-id draft:\n%s", verb, out) } if strings.ContainsRune(out, '\u2014') { t.Errorf("instances %s -h has an em dash:\n%s", verb, out) } } }