carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "flag"
8 "net/http"
9 "net/http/httptest"
10 "os"
11 "path/filepath"
12 "strings"
13 "testing"
14 "unicode/utf8"
15
16 "github.com/carlosframework/platform/internal/appconfig"
17)
18
19func messageFlagSet(t *testing.T, args ...string) (*flag.FlagSet, mutationMessageFlags) {
20 t.Helper()
21 fs := flag.NewFlagSet("test", flag.ContinueOnError)
22 flags := addMutationMessageFlags(fs)
23 if err := fs.Parse(args); err != nil {
24 t.Fatal(err)
25 }
26 return fs, flags
27}
28
29func TestMutationMessageExplicitWinsWithoutPrompt(t *testing.T) {
30 oldTTY, oldIn := interactiveTerminal, chooserIn
31 t.Cleanup(func() { interactiveTerminal, chooserIn = oldTTY, oldIn })
32 interactiveTerminal = func() bool { return true }
33 chooserIn = strings.NewReader("should not be read\n")
34 fs, flags := messageFlagSet(t, "--message", " explain the release ", "--no-prompt")
35 got, err := resolveMutationMessage(fs, flags)
36 if err != nil || got != "explain the release" {
37 t.Fatalf("resolve = %q, %v", got, err)
38 }
39}
40
41func TestMutationMessageExplicitEmptyAndNoPromptSkipTTYPrompt(t *testing.T) {
42 oldTTY, oldIn := interactiveTerminal, chooserIn
43 t.Cleanup(func() { interactiveTerminal, chooserIn = oldTTY, oldIn })
44 interactiveTerminal = func() bool { return true }
45 for _, args := range [][]string{{"--message", ""}, {"--no-prompt"}} {
46 chooserIn = strings.NewReader("must not be read\n")
47 fs, flags := messageFlagSet(t, args...)
48 got, err := resolveMutationMessage(fs, flags)
49 if err != nil || got != "" {
50 t.Fatalf("resolve %v = %q, %v", args, got, err)
51 }
52 }
53}
54
55func TestMutationMessagePromptsOnlyAtTerminal(t *testing.T) {
56 oldTTY, oldIn, oldOut := interactiveTerminal, chooserIn, stdout
57 t.Cleanup(func() { interactiveTerminal, chooserIn, stdout = oldTTY, oldIn, oldOut })
58 var out bytes.Buffer
59 stdout = &out
60 interactiveTerminal = func() bool { return true }
61 chooserIn = strings.NewReader("describe this change\n")
62 t.Setenv("CARLOS_CONFIG", filepath.Join(t.TempDir(), "missing-config"))
63 fs, flags := messageFlagSet(t)
64 got, err := resolveMutationMessage(fs, flags)
65 if err != nil || got != "describe this change" {
66 t.Fatalf("terminal resolve = %q, %v", got, err)
67 }
68
69 interactiveTerminal = func() bool { return false }
70 chooserIn = strings.NewReader("must not be read\n")
71 fs, flags = messageFlagSet(t)
72 got, err = resolveMutationMessage(fs, flags)
73 if err != nil || got != "" {
74 t.Fatalf("non-terminal resolve = %q, %v", got, err)
75 }
76}
77
78func TestMutationMessageValidatesPromptAnswer(t *testing.T) {
79 oldTTY, oldIn, oldOut := interactiveTerminal, chooserIn, stdout
80 t.Cleanup(func() { interactiveTerminal, chooserIn, stdout = oldTTY, oldIn, oldOut })
81 interactiveTerminal = func() bool { return true }
82 chooserIn = bytes.NewReader([]byte{0xff, '\n'})
83 stdout = &bytes.Buffer{}
84 t.Setenv("CARLOS_CONFIG", filepath.Join(t.TempDir(), "missing-config"))
85 fs, flags := messageFlagSet(t)
86 if _, err := resolveMutationMessage(fs, flags); err == nil {
87 t.Fatal("invalid UTF-8 prompt answer succeeded")
88 }
89}
90
91func TestMutationMessagesTravelOnJSONWrites(t *testing.T) {
92 got := map[string]string{}
93 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
94 var body map[string]any
95 if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
96 t.Errorf("%s: decode body: %v", r.URL.Path, err)
97 }
98 got[r.URL.Path] = body["message"].(string)
99 switch {
100 case strings.HasSuffix(r.URL.Path, "/restart"):
101 json.NewEncoder(w).Encode(map[string]any{"account": "bab", "app": "titogo"})
102 case strings.HasSuffix(r.URL.Path, "/promote"):
103 json.NewEncoder(w).Encode(map[string]any{"result": "promoted"})
104 case strings.HasSuffix(r.URL.Path, "/features"):
105 json.NewEncoder(w).Encode(map[string]any{"account": "bab", "app": "titogo", "features": map[string]string{}, "changed": true})
106 case strings.HasSuffix(r.URL.Path, "/config"):
107 json.NewEncoder(w).Encode(map[string]any{"result": "ok", "removed": 0})
108 case strings.HasSuffix(r.URL.Path, "/instances"):
109 w.WriteHeader(http.StatusCreated)
110 json.NewEncoder(w).Encode(map[string]any{"host": "web.example.test"})
111 default:
112 json.NewEncoder(w).Encode(map[string]any{"result": "ok"})
113 }
114 }))
115 defer ts.Close()
116
117 s := newTestSession(ts.URL)
118 a := apiAcct{Seg: "bab"}
119 ctx := context.Background()
120 if _, err := s.restart(ctx, a, "titogo", "restart after the config change"); err != nil {
121 t.Fatal(err)
122 }
123 if _, err := s.promote(ctx, a, "titogo", "abc1234", "edge", false, false, "release the new checkout"); err != nil {
124 t.Fatal(err)
125 }
126 if err := s.rollback(ctx, a, "titogo", "edge", "restore the previous checkout"); err != nil {
127 t.Fatal(err)
128 }
129 if _, err := s.setFeatures(ctx, a, "titogo", map[string]string{"beta": "on"}, nil, "open beta access"); err != nil {
130 t.Fatal(err)
131 }
132 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 {
133 t.Fatal(err)
134 }
135 if _, err := s.instanceCreate(ctx, a, "titogo", "web.example.test", "", "edge", "", "", nil, nil, nil, "add the web copy"); err != nil {
136 t.Fatal(err)
137 }
138
139 for path, want := range map[string]string{
140 "/api/cli/apps/bab/titogo/restart": "restart after the config change",
141 "/api/cli/apps/bab/titogo/promote": "release the new checkout",
142 "/api/cli/apps/bab/titogo/rollback": "restore the previous checkout",
143 "/api/cli/apps/bab/titogo/features": "open beta access",
144 "/api/cli/apps/bab/titogo/config": "switch to live mode",
145 "/api/cli/apps/bab/titogo/instances": "add the web copy",
146 } {
147 if got[path] != want {
148 t.Errorf("%s message = %q, want %q", path, got[path], want)
149 }
150 }
151}
152
153func TestMutationMessageTravelsInShipMetadata(t *testing.T) {
154 var got string
155 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
156 mr, err := r.MultipartReader()
157 if err != nil {
158 t.Error(err)
159 return
160 }
161 part, err := mr.NextPart()
162 if err != nil {
163 t.Error(err)
164 return
165 }
166 var meta map[string]any
167 if err := json.NewDecoder(part).Decode(&meta); err != nil {
168 t.Error(err)
169 return
170 }
171 got, _ = meta["message"].(string)
172 w.WriteHeader(http.StatusCreated)
173 json.NewEncoder(w).Encode(map[string]any{"version": "abc1234", "artifacts": map[string]any{}})
174 }))
175 defer ts.Close()
176
177 artifact := filepath.Join(t.TempDir(), "linux-arm64")
178 if err := os.WriteFile(artifact, []byte("binary"), 0o755); err != nil {
179 t.Fatal(err)
180 }
181 _, err := newTestSession(ts.URL).ship(context.Background(), apiAcct{Seg: "bab"}, "titogo",
182 "abc1234", "binary", "", "", map[string]string{"linux-arm64": artifact}, nil, false,
183 "ship the checkout fix")
184 if err != nil {
185 t.Fatal(err)
186 }
187 if got != "ship the checkout fix" {
188 t.Fatalf("ship metadata message = %q", got)
189 }
190}
191
192func TestMutationMessageTravelsOnDeleteHeader(t *testing.T) {
193 var got string
194 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
195 got = r.Header.Get("X-Carlos-Message")
196 w.WriteHeader(http.StatusOK)
197 }))
198 defer ts.Close()
199 if err := newTestSession(ts.URL).instanceDelete(context.Background(), apiAcct{Seg: "bab"},
200 "titogo", "old.example.test", "retire the old café copy"); err != nil {
201 t.Fatal(err)
202 }
203 if got != "retire the old café copy" {
204 t.Fatalf("delete message header = %q", got)
205 }
206}
207
208func TestMutationMessageConfigSuppressesPrompt(t *testing.T) {
209 oldTTY, oldIn := interactiveTerminal, chooserIn
210 t.Cleanup(func() { interactiveTerminal, chooserIn = oldTTY, oldIn })
211 interactiveTerminal = func() bool { return true }
212 chooserIn = strings.NewReader("must not be read\n")
213 path := filepath.Join(t.TempDir(), "config")
214 if err := os.WriteFile(path, []byte(`{"message_prompt":"off"}`), 0o644); err != nil {
215 t.Fatal(err)
216 }
217 t.Setenv("CARLOS_CONFIG", path)
218 fs, flags := messageFlagSet(t)
219 got, err := resolveMutationMessage(fs, flags)
220 if err != nil || got != "" {
221 t.Fatalf("resolve = %q, %v", got, err)
222 }
223}
224
225func TestMutationMessageValidation(t *testing.T) {
226 if got, err := validateMutationMessage(strings.Repeat("界", mutationMessageMaxRunes)); err != nil || utf8.RuneCountInString(got) != mutationMessageMaxRunes {
227 t.Fatalf("240-rune multibyte message = %d runes, %v", utf8.RuneCountInString(got), err)
228 }
229 for _, bad := range []string{"two\nlines", "trailing newline\n", "tab\tseparated", strings.Repeat("x", mutationMessageMaxRunes+1), string([]byte{0xff})} {
230 if _, err := validateMutationMessage(bad); err == nil {
231 t.Errorf("validateMutationMessage(%q) succeeded", bad)
232 }
233 }
234}
235