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 "encoding/json"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10)
11
12// destinationsEnv is a scratch project with no config anywhere, no
13// credentials, no destination selected and no terminal. It returns the
14// project directory, which is the working directory.
15func destinationsEnv(t *testing.T) string {
16 t.Helper()
17 useTempCredentials(t)
18 t.Setenv("CARLOS_CONFIG", "")
19 t.Setenv("CARLOS_DESTINATION", "")
20 selectDestination(t, "")
21 forceTerminal(t, false)
22 project := t.TempDir()
23 t.Chdir(project)
24 return project
25}
26
27func runDestinations(args ...string) (string, error) {
28 var b bytes.Buffer
29 err := configDestinations(&b, args)
30 return b.String(), err
31}
32
33func mustDestinations(t *testing.T, args ...string) string {
34 t.Helper()
35 out, err := runDestinations(args...)
36 if err != nil {
37 t.Fatalf("config destinations %v: %v\n%s", args, err, out)
38 }
39 return out
40}
41
42func readConfigJSON(t *testing.T, path string) map[string]any {
43 t.Helper()
44 raw, err := os.ReadFile(path)
45 if err != nil {
46 t.Fatal(err)
47 }
48 var m map[string]any
49 if err := json.Unmarshal(raw, &m); err != nil {
50 t.Fatal(err)
51 }
52 return m
53}
54
55func destBlock(t *testing.T, m map[string]any, name string) map[string]any {
56 t.Helper()
57 b, ok := m["destinations"].(map[string]any)[name].(map[string]any)
58 if !ok {
59 t.Fatalf("no destination %q in %v", name, m)
60 }
61 return b
62}
63
64// Adding to a file that already deploys somewhere must not silently move
65// it: without a terminal the add is refused until the current settings
66// are named, and --from-current moves exactly the where-keys into a block
67// that stays the default. Shared build keys stay at the top.
68func TestDestinationsAddConvertsAFlatFile(t *testing.T) {
69 project := destinationsEnv(t)
70 path := writeConfig(t, project, `{"console":"https://a.example.com","account":"acme","app":"shop","artifact":"./bin/server","update_check":"off"}`)
71
72 if _, err := runDestinations("add", "tito", "--console", "https://tito.example.com"); err == nil ||
73 !strings.Contains(err.Error(), "--from-current") {
74 t.Fatalf("flat-file add without --from-current = %v, want a refusal naming the flag", err)
75 }
76 if m := readConfigJSON(t, path); m["destinations"] != nil {
77 t.Fatalf("a refused add wrote the file: %v", m)
78 }
79
80 mustDestinations(t, "add", "tito", "--console", "https://tito.example.com", "--from-current", "exciting")
81 m := readConfigJSON(t, path)
82 ex := destBlock(t, m, "exciting")
83 if ex["console"] != "https://a.example.com" || ex["account"] != "acme" || ex["app"] != "shop" {
84 t.Fatalf("moved block = %v", ex)
85 }
86 if m["console"] != nil || m["account"] != nil || m["app"] != nil {
87 t.Fatalf("where-keys left at the top: %v", m)
88 }
89 if m["artifact"] != "./bin/server" || m["update_check"] != "off" || m["default"] != "exciting" {
90 t.Fatalf("shared keys or default wrong: %v", m)
91 }
92
93 // What loadConfig makes of it: bare → exciting as before; -d tito →
94 // tito's console, inheriting the shared artifact.
95 if cfg, err := loadConfig(path); err != nil || cfg.Console != "https://a.example.com" || cfg.App != "shop" {
96 t.Fatalf("bare read after convert = %+v, %v", cfg, err)
97 }
98 selectDestination(t, "tito")
99 if cfg, err := loadConfig(path); err != nil || cfg.Console != "https://tito.example.com" || cfg.Artifact != "./bin/server" {
100 t.Fatalf("tito read = %+v, %v", cfg, err)
101 }
102}
103
104// At a terminal the name for the current settings is asked for instead.
105func TestDestinationsAddConvertsAtATerminal(t *testing.T) {
106 project := destinationsEnv(t)
107 forceTerminal(t, true)
108 old := chooserIn
109 chooserIn = strings.NewReader("exciting\n")
110 t.Cleanup(func() { chooserIn = old })
111 path := writeConfig(t, project, `{"console":"https://a.example.com"}`)
112 out := mustDestinations(t, "add", "tito", "--console", "https://tito.example.com")
113 if !strings.Contains(out, "name for it:") {
114 t.Fatalf("no prompt:\n%s", out)
115 }
116 if m := readConfigJSON(t, path); destBlock(t, m, "exciting")["console"] != "https://a.example.com" || m["default"] != "exciting" {
117 t.Fatalf("terminal convert = %v", m)
118 }
119}
120
121func TestDestinationsAdd(t *testing.T) {
122 project := destinationsEnv(t)
123 path := filepath.Join(project, ".carlos", "config")
124
125 // Flags on either side of the name; the console is normalized; the
126 // file and its directory are created.
127 out := mustDestinations(t, "add", "--console", "https://a.example.com/", "prod", "-a", "shop", "--kind", "binary")
128 if !strings.Contains(out, "not logged in to https://a.example.com") {
129 t.Fatalf("no login warning:\n%s", out)
130 }
131 m := readConfigJSON(t, path)
132 if p := destBlock(t, m, "prod"); p["console"] != "https://a.example.com" || p["app"] != "shop" || p["kind"] != "binary" {
133 t.Fatalf("prod = %v", p)
134 }
135 if m["default"] != nil {
136 t.Fatalf("a lone destination needs no default: %v", m)
137 }
138
139 // The second add pins the first as the default, so bare commands do
140 // not start refusing.
141 out = mustDestinations(t, "add", "staging", "--console", "https://a.example.com", "--app", "shop-staging")
142 if !strings.Contains(out, "prod stays the default") {
143 t.Fatalf("no default note:\n%s", out)
144 }
145 if m = readConfigJSON(t, path); m["default"] != "prod" {
146 t.Fatalf("default = %v", m["default"])
147 }
148 // --default moves it.
149 mustDestinations(t, "add", "eu", "--console", "https://eu.example.com", "--default")
150 if m = readConfigJSON(t, path); m["default"] != "eu" {
151 t.Fatalf("--default = %v", m["default"])
152 }
153
154 for _, c := range []struct {
155 args []string
156 want string
157 }{
158 {[]string{"add", "prod", "--console", "https://x.example.com"}, "already has"},
159 {[]string{"add", "noconsole", "--app", "x"}, "no console"},
160 {[]string{"add", "bad name", "--console", "https://x.example.com"}, "not a usable destination name"},
161 {[]string{"add", "k", "--console", "https://x.example.com", "--kind", "docker"}, "binary or static"},
162 {[]string{"add", "u", "--console", "ftp://x"}, ""},
163 {[]string{"add", "f", "--console", "https://x.example.com", "--from-current", "g"}, "--from-current is only for"},
164 {[]string{"add"}, "usage:"},
165 } {
166 if _, err := runDestinations(c.args...); err == nil || !strings.Contains(err.Error(), c.want) {
167 t.Errorf("%v = %v, want an error containing %q", c.args, err, c.want)
168 }
169 }
170 if m2 := readConfigJSON(t, path); len(m2["destinations"].(map[string]any)) != 3 {
171 t.Fatalf("a refused add changed the file: %v", m2)
172 }
173}
174
175// A console this machine is logged in to draws no warning.
176func TestDestinationsAddLoggedInIsQuiet(t *testing.T) {
177 destinationsEnv(t)
178 f := &credentialFile{Credentials: []credential{{Console: "https://a.example.com", Address: "me@example.com", Token: "t"}}}
179 if err := f.save(); err != nil {
180 t.Fatal(err)
181 }
182 if out := mustDestinations(t, "add", "prod", "--console", "https://a.example.com"); strings.Contains(out, "not logged in") {
183 t.Fatalf("warned despite a credential:\n%s", out)
184 }
185}
186
187func TestDestinationsSet(t *testing.T) {
188 project := destinationsEnv(t)
189 path := writeConfig(t, project, `{"destinations":{"prod":{"console":"https://a.example.com","app":"shop","kind":"binary"}}}`)
190
191 mustDestinations(t, "set", "prod", "--app", "", "--kind", "static", "--artifact", "./dist")
192 p := destBlock(t, readConfigJSON(t, path), "prod")
193 if _, has := p["app"]; has || p["kind"] != "static" || p["artifact"] != "./dist" || p["console"] != "https://a.example.com" {
194 t.Fatalf("after set = %v", p)
195 }
196 for _, c := range []struct {
197 args []string
198 want string
199 }{
200 {[]string{"set", "prod"}, "nothing to change"},
201 {[]string{"set", "nope", "--app", "x"}, `no destination "nope"`},
202 {[]string{"set", "prod", "--console", ""}, "no console"},
203 {[]string{"set", "prod", "--kind", "nope"}, "binary or static"},
204 } {
205 if _, err := runDestinations(c.args...); err == nil || !strings.Contains(err.Error(), c.want) {
206 t.Errorf("%v = %v, want an error containing %q", c.args, err, c.want)
207 }
208 }
209}
210
211func TestDestinationsListAndDefault(t *testing.T) {
212 project := destinationsEnv(t)
213 if out := mustDestinations(t); !strings.Contains(out, "no destinations in") {
214 t.Fatalf("empty list = %q", out)
215 }
216 path := writeConfig(t, project, `{"artifact":"./bin/server","destinations":{
217 "prod":{"console":"https://a.example.com","app":"shop"},
218 "eu":{"console":"https://eu.example.com","app":"shop-eu"}}}`)
219
220 // Several and no default: list still works (it is how you find out),
221 // and says what is wrong; default without a name refuses the same way.
222 out := mustDestinations(t, "list")
223 for _, want := range []string{"prod", "https://eu.example.com", "shop-eu", "./bin/server", "no default"} {
224 if !strings.Contains(out, want) {
225 t.Fatalf("list missing %q:\n%s", want, out)
226 }
227 }
228 if _, err := runDestinations("default"); err == nil {
229 t.Fatal("default with several and none set must refuse")
230 }
231
232 mustDestinations(t, "default", "eu")
233 if m := readConfigJSON(t, path); m["default"] != "eu" {
234 t.Fatalf("default = %v", m["default"])
235 }
236 if out = mustDestinations(t, "default"); !strings.HasPrefix(out, "eu (from ") {
237 t.Fatalf("show default = %q", out)
238 }
239 if out = mustDestinations(t); !strings.Contains(out, "* eu") || strings.Contains(out, "* prod") {
240 t.Fatalf("list default mark:\n%s", out)
241 }
242 if _, err := runDestinations("default", "nope"); err == nil {
243 t.Fatal("default naming no destination must refuse")
244 }
245}
246
247func TestDestinationsRemoveAndRename(t *testing.T) {
248 project := destinationsEnv(t)
249 path := writeConfig(t, project, `{"default":"prod","destinations":{
250 "prod":{"console":"https://a.example.com"},
251 "eu":{"console":"https://eu.example.com"},
252 "staging":{"console":"https://a.example.com"}}}`)
253
254 // Rename carries the default with it.
255 mustDestinations(t, "rename", "prod", "production")
256 m := readConfigJSON(t, path)
257 if m["default"] != "production" || destBlock(t, m, "production")["console"] != "https://a.example.com" {
258 t.Fatalf("after rename = %v", m)
259 }
260 if _, err := runDestinations("rename", "eu", "staging"); err == nil {
261 t.Fatal("rename onto an existing name must refuse")
262 }
263
264 // The default cannot be removed while two others would be left.
265 if _, err := runDestinations("remove", "production"); err == nil || !strings.Contains(err.Error(), "is the default") {
266 t.Fatalf("remove default = %v", err)
267 }
268 mustDestinations(t, "remove", "staging")
269 // Now one would be left: removing the default promotes it.
270 out := mustDestinations(t, "remove", "production")
271 if !strings.Contains(out, "eu is now the default") {
272 t.Fatalf("promotion note missing:\n%s", out)
273 }
274 if m = readConfigJSON(t, path); m["default"] != nil {
275 t.Fatalf("stale default left: %v", m)
276 }
277 // Removing the last clears both keys.
278 mustDestinations(t, "remove", "eu")
279 if m = readConfigJSON(t, path); m["destinations"] != nil || m["default"] != nil {
280 t.Fatalf("after last remove = %v", m)
281 }
282}
283
284// The file a verb edits: the nearest project file walking up, never the
285// global one met on the way up through $HOME; --global asks for that.
286func TestDestinationsFile(t *testing.T) {
287 destinationsEnv(t)
288 home := os.Getenv("HOME")
289 project := filepath.Join(home, "code", "shop")
290 deep := filepath.Join(project, "cmd", "server")
291 if err := os.MkdirAll(deep, 0o755); err != nil {
292 t.Fatal(err)
293 }
294 global := writeConfig(t, home, `{"console":"https://global.example.com"}`)
295 t.Chdir(deep)
296
297 // Nothing in the project: a new file right here, not the global.
298 p, _, err := destinationsFile(false)
299 if err != nil || p != filepath.Join(deep, ".carlos", "config") {
300 t.Fatalf("no project file = %q %v", p, err)
301 }
302 // A project file further up is the one edited.
303 want := writeConfig(t, project, `{}`)
304 if p, _, err = destinationsFile(false); err != nil || p != want {
305 t.Fatalf("nearest = %q %v, want %q", p, err, want)
306 }
307 if p, _, err = destinationsFile(true); err != nil || p != global {
308 t.Fatalf("--global = %q %v", p, err)
309 }
310 mustDestinations(t, "add", "g", "--console", "https://g.example.com", "--global", "--from-current", "home")
311 if destBlock(t, readConfigJSON(t, global), "g")["console"] != "https://g.example.com" {
312 t.Fatal("--global add missed the global file")
313 }
314 // In $HOME itself, ./.carlos/config IS the global file: refuse rather
315 // than edit it by accident.
316 t.Chdir(home)
317 if _, _, err = destinationsFile(false); err == nil {
318 t.Fatal("editing ~/.carlos/config without --global must refuse")
319 }
320}
321
322// auth default -d reads and writes one destination's console.
323func TestAuthDefaultDestination(t *testing.T) {
324 project := destinationsEnv(t)
325 path := writeConfig(t, project, `{"default":"prod","destinations":{"prod":{"console":"https://a.example.com"}}}`)
326 var b bytes.Buffer
327 if err := authDefault(&b, []string{"--project", "-d", "tito", "https://tito.example.com"}); err != nil {
328 t.Fatal(err)
329 }
330 if !strings.Contains(b.String(), "the tito destination in") {
331 t.Fatalf("write echo = %q", b.String())
332 }
333 if destBlock(t, readConfigJSON(t, path), "tito")["console"] != "https://tito.example.com" {
334 t.Fatal("auth default -d did not write the tito block")
335 }
336 b.Reset()
337 if err := authDefault(&b, []string{"-d", "tito"}); err != nil || !strings.Contains(b.String(), "https://tito.example.com (from the tito destination in") {
338 t.Fatalf("show = %q %v", b.String(), err)
339 }
340}
341