package main import ( "bytes" "context" "encoding/json" "flag" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "unicode/utf8" "github.com/carlosframework/platform/internal/appconfig" ) func messageFlagSet(t *testing.T, args ...string) (*flag.FlagSet, mutationMessageFlags) { t.Helper() fs := flag.NewFlagSet("test", flag.ContinueOnError) flags := addMutationMessageFlags(fs) if err := fs.Parse(args); err != nil { t.Fatal(err) } return fs, flags } func TestMutationMessageExplicitWinsWithoutPrompt(t *testing.T) { oldTTY, oldIn := interactiveTerminal, chooserIn t.Cleanup(func() { interactiveTerminal, chooserIn = oldTTY, oldIn }) interactiveTerminal = func() bool { return true } chooserIn = strings.NewReader("should not be read\n") fs, flags := messageFlagSet(t, "--message", " explain the release ", "--no-prompt") got, err := resolveMutationMessage(fs, flags) if err != nil || got != "explain the release" { t.Fatalf("resolve = %q, %v", got, err) } } func TestMutationMessageExplicitEmptyAndNoPromptSkipTTYPrompt(t *testing.T) { oldTTY, oldIn := interactiveTerminal, chooserIn t.Cleanup(func() { interactiveTerminal, chooserIn = oldTTY, oldIn }) interactiveTerminal = func() bool { return true } for _, args := range [][]string{{"--message", ""}, {"--no-prompt"}} { chooserIn = strings.NewReader("must not be read\n") fs, flags := messageFlagSet(t, args...) got, err := resolveMutationMessage(fs, flags) if err != nil || got != "" { t.Fatalf("resolve %v = %q, %v", args, got, err) } } } func TestMutationMessagePromptsOnlyAtTerminal(t *testing.T) { oldTTY, oldIn, oldOut := interactiveTerminal, chooserIn, stdout t.Cleanup(func() { interactiveTerminal, chooserIn, stdout = oldTTY, oldIn, oldOut }) var out bytes.Buffer stdout = &out interactiveTerminal = func() bool { return true } chooserIn = strings.NewReader("describe this change\n") t.Setenv("CARLOS_CONFIG", filepath.Join(t.TempDir(), "missing-config")) fs, flags := messageFlagSet(t) got, err := resolveMutationMessage(fs, flags) if err != nil || got != "describe this change" { t.Fatalf("terminal resolve = %q, %v", got, err) } interactiveTerminal = func() bool { return false } chooserIn = strings.NewReader("must not be read\n") fs, flags = messageFlagSet(t) got, err = resolveMutationMessage(fs, flags) if err != nil || got != "" { t.Fatalf("non-terminal resolve = %q, %v", got, err) } } func TestMutationMessageValidatesPromptAnswer(t *testing.T) { oldTTY, oldIn, oldOut := interactiveTerminal, chooserIn, stdout t.Cleanup(func() { interactiveTerminal, chooserIn, stdout = oldTTY, oldIn, oldOut }) interactiveTerminal = func() bool { return true } chooserIn = bytes.NewReader([]byte{0xff, '\n'}) stdout = &bytes.Buffer{} t.Setenv("CARLOS_CONFIG", filepath.Join(t.TempDir(), "missing-config")) fs, flags := messageFlagSet(t) if _, err := resolveMutationMessage(fs, flags); err == nil { t.Fatal("invalid UTF-8 prompt answer succeeded") } } func TestMutationMessagesTravelOnJSONWrites(t *testing.T) { got := map[string]string{} ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body map[string]any if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Errorf("%s: decode body: %v", r.URL.Path, err) } got[r.URL.Path] = body["message"].(string) switch { case strings.HasSuffix(r.URL.Path, "/restart"): json.NewEncoder(w).Encode(map[string]any{"account": "bab", "app": "titogo"}) case strings.HasSuffix(r.URL.Path, "/promote"): json.NewEncoder(w).Encode(map[string]any{"result": "promoted"}) case strings.HasSuffix(r.URL.Path, "/features"): json.NewEncoder(w).Encode(map[string]any{"account": "bab", "app": "titogo", "features": map[string]string{}, "changed": true}) case strings.HasSuffix(r.URL.Path, "/config"): json.NewEncoder(w).Encode(map[string]any{"result": "ok", "removed": 0}) case strings.HasSuffix(r.URL.Path, "/instances"): w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]any{"host": "web.example.test"}) default: json.NewEncoder(w).Encode(map[string]any{"result": "ok"}) } })) defer ts.Close() s := newTestSession(ts.URL) a := apiAcct{Seg: "bab"} ctx := context.Background() if _, err := s.restart(ctx, a, "titogo", "restart after the config change"); err != nil { t.Fatal(err) } if _, err := s.promote(ctx, a, "titogo", "abc1234", "edge", false, false, "release the new checkout"); err != nil { t.Fatal(err) } if err := s.rollback(ctx, a, "titogo", "edge", "restore the previous checkout"); err != nil { t.Fatal(err) } if _, err := s.setFeatures(ctx, a, "titogo", map[string]string{"beta": "on"}, nil, "open beta access"); err != nil { t.Fatal(err) } if _, err := s.configApply(ctx, a, "titogo", apiConfigApply{Set: map[string]appconfig.Var{"MODE": {Kind: "env", Value: "live"}}, Message: "switch to live mode"}); err != nil { t.Fatal(err) } if _, err := s.instanceCreate(ctx, a, "titogo", "web.example.test", "", "edge", "", "", nil, nil, nil, "add the web copy"); err != nil { t.Fatal(err) } for path, want := range map[string]string{ "/api/cli/apps/bab/titogo/restart": "restart after the config change", "/api/cli/apps/bab/titogo/promote": "release the new checkout", "/api/cli/apps/bab/titogo/rollback": "restore the previous checkout", "/api/cli/apps/bab/titogo/features": "open beta access", "/api/cli/apps/bab/titogo/config": "switch to live mode", "/api/cli/apps/bab/titogo/instances": "add the web copy", } { if got[path] != want { t.Errorf("%s message = %q, want %q", path, got[path], want) } } } func TestMutationMessageTravelsInShipMetadata(t *testing.T) { var got string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mr, err := r.MultipartReader() if err != nil { t.Error(err) return } part, err := mr.NextPart() if err != nil { t.Error(err) return } var meta map[string]any if err := json.NewDecoder(part).Decode(&meta); err != nil { t.Error(err) return } got, _ = meta["message"].(string) w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]any{"version": "abc1234", "artifacts": map[string]any{}}) })) defer ts.Close() artifact := filepath.Join(t.TempDir(), "linux-arm64") if err := os.WriteFile(artifact, []byte("binary"), 0o755); err != nil { t.Fatal(err) } _, err := newTestSession(ts.URL).ship(context.Background(), apiAcct{Seg: "bab"}, "titogo", "abc1234", "binary", "", "", map[string]string{"linux-arm64": artifact}, nil, false, "ship the checkout fix") if err != nil { t.Fatal(err) } if got != "ship the checkout fix" { t.Fatalf("ship metadata message = %q", got) } } func TestMutationMessageTravelsOnDeleteHeader(t *testing.T) { var got string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got = r.Header.Get("X-Carlos-Message") w.WriteHeader(http.StatusOK) })) defer ts.Close() if err := newTestSession(ts.URL).instanceDelete(context.Background(), apiAcct{Seg: "bab"}, "titogo", "old.example.test", "retire the old café copy"); err != nil { t.Fatal(err) } if got != "retire the old café copy" { t.Fatalf("delete message header = %q", got) } } func TestMutationMessageConfigSuppressesPrompt(t *testing.T) { oldTTY, oldIn := interactiveTerminal, chooserIn t.Cleanup(func() { interactiveTerminal, chooserIn = oldTTY, oldIn }) interactiveTerminal = func() bool { return true } chooserIn = strings.NewReader("must not be read\n") path := filepath.Join(t.TempDir(), "config") if err := os.WriteFile(path, []byte(`{"message_prompt":"off"}`), 0o644); err != nil { t.Fatal(err) } t.Setenv("CARLOS_CONFIG", path) fs, flags := messageFlagSet(t) got, err := resolveMutationMessage(fs, flags) if err != nil || got != "" { t.Fatalf("resolve = %q, %v", got, err) } } func TestMutationMessageValidation(t *testing.T) { if got, err := validateMutationMessage(strings.Repeat("界", mutationMessageMaxRunes)); err != nil || utf8.RuneCountInString(got) != mutationMessageMaxRunes { t.Fatalf("240-rune multibyte message = %d runes, %v", utf8.RuneCountInString(got), err) } for _, bad := range []string{"two\nlines", "trailing newline\n", "tab\tseparated", strings.Repeat("x", mutationMessageMaxRunes+1), string([]byte{0xff})} { if _, err := validateMutationMessage(bad); err == nil { t.Errorf("validateMutationMessage(%q) succeeded", bad) } } }