package main import ( "bytes" "encoding/json" "os" "path/filepath" "strings" "testing" ) // destinationsEnv is a scratch project with no config anywhere, no // credentials, no destination selected and no terminal. It returns the // project directory, which is the working directory. func destinationsEnv(t *testing.T) string { t.Helper() useTempCredentials(t) t.Setenv("CARLOS_CONFIG", "") t.Setenv("CARLOS_DESTINATION", "") selectDestination(t, "") forceTerminal(t, false) project := t.TempDir() t.Chdir(project) return project } func runDestinations(args ...string) (string, error) { var b bytes.Buffer err := configDestinations(&b, args) return b.String(), err } func mustDestinations(t *testing.T, args ...string) string { t.Helper() out, err := runDestinations(args...) if err != nil { t.Fatalf("config destinations %v: %v\n%s", args, err, out) } return out } func readConfigJSON(t *testing.T, path string) map[string]any { t.Helper() raw, err := os.ReadFile(path) if err != nil { t.Fatal(err) } var m map[string]any if err := json.Unmarshal(raw, &m); err != nil { t.Fatal(err) } return m } func destBlock(t *testing.T, m map[string]any, name string) map[string]any { t.Helper() b, ok := m["destinations"].(map[string]any)[name].(map[string]any) if !ok { t.Fatalf("no destination %q in %v", name, m) } return b } // Adding to a file that already deploys somewhere must not silently move // it: without a terminal the add is refused until the current settings // are named, and --from-current moves exactly the where-keys into a block // that stays the default. Shared build keys stay at the top. func TestDestinationsAddConvertsAFlatFile(t *testing.T) { project := destinationsEnv(t) path := writeConfig(t, project, `{"console":"https://a.example.com","account":"acme","app":"shop","artifact":"./bin/server","update_check":"off"}`) if _, err := runDestinations("add", "tito", "--console", "https://tito.example.com"); err == nil || !strings.Contains(err.Error(), "--from-current") { t.Fatalf("flat-file add without --from-current = %v, want a refusal naming the flag", err) } if m := readConfigJSON(t, path); m["destinations"] != nil { t.Fatalf("a refused add wrote the file: %v", m) } mustDestinations(t, "add", "tito", "--console", "https://tito.example.com", "--from-current", "exciting") m := readConfigJSON(t, path) ex := destBlock(t, m, "exciting") if ex["console"] != "https://a.example.com" || ex["account"] != "acme" || ex["app"] != "shop" { t.Fatalf("moved block = %v", ex) } if m["console"] != nil || m["account"] != nil || m["app"] != nil { t.Fatalf("where-keys left at the top: %v", m) } if m["artifact"] != "./bin/server" || m["update_check"] != "off" || m["default"] != "exciting" { t.Fatalf("shared keys or default wrong: %v", m) } // What loadConfig makes of it: bare → exciting as before; -d tito → // tito's console, inheriting the shared artifact. if cfg, err := loadConfig(path); err != nil || cfg.Console != "https://a.example.com" || cfg.App != "shop" { t.Fatalf("bare read after convert = %+v, %v", cfg, err) } selectDestination(t, "tito") if cfg, err := loadConfig(path); err != nil || cfg.Console != "https://tito.example.com" || cfg.Artifact != "./bin/server" { t.Fatalf("tito read = %+v, %v", cfg, err) } } // At a terminal the name for the current settings is asked for instead. func TestDestinationsAddConvertsAtATerminal(t *testing.T) { project := destinationsEnv(t) forceTerminal(t, true) old := chooserIn chooserIn = strings.NewReader("exciting\n") t.Cleanup(func() { chooserIn = old }) path := writeConfig(t, project, `{"console":"https://a.example.com"}`) out := mustDestinations(t, "add", "tito", "--console", "https://tito.example.com") if !strings.Contains(out, "name for it:") { t.Fatalf("no prompt:\n%s", out) } if m := readConfigJSON(t, path); destBlock(t, m, "exciting")["console"] != "https://a.example.com" || m["default"] != "exciting" { t.Fatalf("terminal convert = %v", m) } } func TestDestinationsAdd(t *testing.T) { project := destinationsEnv(t) path := filepath.Join(project, ".carlos", "config") // Flags on either side of the name; the console is normalized; the // file and its directory are created. out := mustDestinations(t, "add", "--console", "https://a.example.com/", "prod", "-a", "shop", "--kind", "binary") if !strings.Contains(out, "not logged in to https://a.example.com") { t.Fatalf("no login warning:\n%s", out) } m := readConfigJSON(t, path) if p := destBlock(t, m, "prod"); p["console"] != "https://a.example.com" || p["app"] != "shop" || p["kind"] != "binary" { t.Fatalf("prod = %v", p) } if m["default"] != nil { t.Fatalf("a lone destination needs no default: %v", m) } // The second add pins the first as the default, so bare commands do // not start refusing. out = mustDestinations(t, "add", "staging", "--console", "https://a.example.com", "--app", "shop-staging") if !strings.Contains(out, "prod stays the default") { t.Fatalf("no default note:\n%s", out) } if m = readConfigJSON(t, path); m["default"] != "prod" { t.Fatalf("default = %v", m["default"]) } // --default moves it. mustDestinations(t, "add", "eu", "--console", "https://eu.example.com", "--default") if m = readConfigJSON(t, path); m["default"] != "eu" { t.Fatalf("--default = %v", m["default"]) } for _, c := range []struct { args []string want string }{ {[]string{"add", "prod", "--console", "https://x.example.com"}, "already has"}, {[]string{"add", "noconsole", "--app", "x"}, "no console"}, {[]string{"add", "bad name", "--console", "https://x.example.com"}, "not a usable destination name"}, {[]string{"add", "k", "--console", "https://x.example.com", "--kind", "docker"}, "binary or static"}, {[]string{"add", "u", "--console", "ftp://x"}, ""}, {[]string{"add", "f", "--console", "https://x.example.com", "--from-current", "g"}, "--from-current is only for"}, {[]string{"add"}, "usage:"}, } { if _, err := runDestinations(c.args...); err == nil || !strings.Contains(err.Error(), c.want) { t.Errorf("%v = %v, want an error containing %q", c.args, err, c.want) } } if m2 := readConfigJSON(t, path); len(m2["destinations"].(map[string]any)) != 3 { t.Fatalf("a refused add changed the file: %v", m2) } } // A console this machine is logged in to draws no warning. func TestDestinationsAddLoggedInIsQuiet(t *testing.T) { destinationsEnv(t) f := &credentialFile{Credentials: []credential{{Console: "https://a.example.com", Address: "me@example.com", Token: "t"}}} if err := f.save(); err != nil { t.Fatal(err) } if out := mustDestinations(t, "add", "prod", "--console", "https://a.example.com"); strings.Contains(out, "not logged in") { t.Fatalf("warned despite a credential:\n%s", out) } } func TestDestinationsSet(t *testing.T) { project := destinationsEnv(t) path := writeConfig(t, project, `{"destinations":{"prod":{"console":"https://a.example.com","app":"shop","kind":"binary"}}}`) mustDestinations(t, "set", "prod", "--app", "", "--kind", "static", "--artifact", "./dist") p := destBlock(t, readConfigJSON(t, path), "prod") if _, has := p["app"]; has || p["kind"] != "static" || p["artifact"] != "./dist" || p["console"] != "https://a.example.com" { t.Fatalf("after set = %v", p) } for _, c := range []struct { args []string want string }{ {[]string{"set", "prod"}, "nothing to change"}, {[]string{"set", "nope", "--app", "x"}, `no destination "nope"`}, {[]string{"set", "prod", "--console", ""}, "no console"}, {[]string{"set", "prod", "--kind", "nope"}, "binary or static"}, } { if _, err := runDestinations(c.args...); err == nil || !strings.Contains(err.Error(), c.want) { t.Errorf("%v = %v, want an error containing %q", c.args, err, c.want) } } } func TestDestinationsListAndDefault(t *testing.T) { project := destinationsEnv(t) if out := mustDestinations(t); !strings.Contains(out, "no destinations in") { t.Fatalf("empty list = %q", out) } path := writeConfig(t, project, `{"artifact":"./bin/server","destinations":{ "prod":{"console":"https://a.example.com","app":"shop"}, "eu":{"console":"https://eu.example.com","app":"shop-eu"}}}`) // Several and no default: list still works (it is how you find out), // and says what is wrong; default without a name refuses the same way. out := mustDestinations(t, "list") for _, want := range []string{"prod", "https://eu.example.com", "shop-eu", "./bin/server", "no default"} { if !strings.Contains(out, want) { t.Fatalf("list missing %q:\n%s", want, out) } } if _, err := runDestinations("default"); err == nil { t.Fatal("default with several and none set must refuse") } mustDestinations(t, "default", "eu") if m := readConfigJSON(t, path); m["default"] != "eu" { t.Fatalf("default = %v", m["default"]) } if out = mustDestinations(t, "default"); !strings.HasPrefix(out, "eu (from ") { t.Fatalf("show default = %q", out) } if out = mustDestinations(t); !strings.Contains(out, "* eu") || strings.Contains(out, "* prod") { t.Fatalf("list default mark:\n%s", out) } if _, err := runDestinations("default", "nope"); err == nil { t.Fatal("default naming no destination must refuse") } } func TestDestinationsRemoveAndRename(t *testing.T) { project := destinationsEnv(t) path := writeConfig(t, project, `{"default":"prod","destinations":{ "prod":{"console":"https://a.example.com"}, "eu":{"console":"https://eu.example.com"}, "staging":{"console":"https://a.example.com"}}}`) // Rename carries the default with it. mustDestinations(t, "rename", "prod", "production") m := readConfigJSON(t, path) if m["default"] != "production" || destBlock(t, m, "production")["console"] != "https://a.example.com" { t.Fatalf("after rename = %v", m) } if _, err := runDestinations("rename", "eu", "staging"); err == nil { t.Fatal("rename onto an existing name must refuse") } // The default cannot be removed while two others would be left. if _, err := runDestinations("remove", "production"); err == nil || !strings.Contains(err.Error(), "is the default") { t.Fatalf("remove default = %v", err) } mustDestinations(t, "remove", "staging") // Now one would be left: removing the default promotes it. out := mustDestinations(t, "remove", "production") if !strings.Contains(out, "eu is now the default") { t.Fatalf("promotion note missing:\n%s", out) } if m = readConfigJSON(t, path); m["default"] != nil { t.Fatalf("stale default left: %v", m) } // Removing the last clears both keys. mustDestinations(t, "remove", "eu") if m = readConfigJSON(t, path); m["destinations"] != nil || m["default"] != nil { t.Fatalf("after last remove = %v", m) } } // The file a verb edits: the nearest project file walking up, never the // global one met on the way up through $HOME; --global asks for that. func TestDestinationsFile(t *testing.T) { destinationsEnv(t) home := os.Getenv("HOME") project := filepath.Join(home, "code", "shop") deep := filepath.Join(project, "cmd", "server") if err := os.MkdirAll(deep, 0o755); err != nil { t.Fatal(err) } global := writeConfig(t, home, `{"console":"https://global.example.com"}`) t.Chdir(deep) // Nothing in the project: a new file right here, not the global. p, _, err := destinationsFile(false) if err != nil || p != filepath.Join(deep, ".carlos", "config") { t.Fatalf("no project file = %q %v", p, err) } // A project file further up is the one edited. want := writeConfig(t, project, `{}`) if p, _, err = destinationsFile(false); err != nil || p != want { t.Fatalf("nearest = %q %v, want %q", p, err, want) } if p, _, err = destinationsFile(true); err != nil || p != global { t.Fatalf("--global = %q %v", p, err) } mustDestinations(t, "add", "g", "--console", "https://g.example.com", "--global", "--from-current", "home") if destBlock(t, readConfigJSON(t, global), "g")["console"] != "https://g.example.com" { t.Fatal("--global add missed the global file") } // In $HOME itself, ./.carlos/config IS the global file: refuse rather // than edit it by accident. t.Chdir(home) if _, _, err = destinationsFile(false); err == nil { t.Fatal("editing ~/.carlos/config without --global must refuse") } } // auth default -d reads and writes one destination's console. func TestAuthDefaultDestination(t *testing.T) { project := destinationsEnv(t) path := writeConfig(t, project, `{"default":"prod","destinations":{"prod":{"console":"https://a.example.com"}}}`) var b bytes.Buffer if err := authDefault(&b, []string{"--project", "-d", "tito", "https://tito.example.com"}); err != nil { t.Fatal(err) } if !strings.Contains(b.String(), "the tito destination in") { t.Fatalf("write echo = %q", b.String()) } if destBlock(t, readConfigJSON(t, path), "tito")["console"] != "https://tito.example.com" { t.Fatal("auth default -d did not write the tito block") } b.Reset() if err := authDefault(&b, []string{"-d", "tito"}); err != nil || !strings.Contains(b.String(), "https://tito.example.com (from the tito destination in") { t.Fatalf("show = %q %v", b.String(), err) } }