| 1 | package main |
| 2 | |
| 3 | import ( |
| 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. |
| 15 | func 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 | |
| 27 | func runDestinations(args ...string) (string, error) { |
| 28 | var b bytes.Buffer |
| 29 | err := configDestinations(&b, args) |
| 30 | return b.String(), err |
| 31 | } |
| 32 | |
| 33 | func 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 | |
| 42 | func 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 | |
| 55 | func 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. |
| 68 | func 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. |
| 105 | func 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 | |
| 121 | func 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. |
| 176 | func 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 | |
| 187 | func 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 | |
| 211 | func 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 | |
| 247 | func 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. |
| 286 | func 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. |
| 323 | func 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 | |