package main import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "strings" "testing" ) // selectDestination sets --destination for one test, and puts it back. func selectDestination(t *testing.T, name string) { t.Helper() prev := destinationFlag destinationFlag = name t.Cleanup(func() { destinationFlag = prev }) } const twoDestinations = `{ "artifact": "./bin/server", "update_check": "off", "destinations": { "exciting": {"console": "https://carlos.exciting.example/", "app": "shop"}, "tito": {"console": "https://tito.example", "app": "shop-tito", "artifact": "./dist"} } }` // Selection, the file's default, the only block, and the refusals — each // resolved through loadConfig, which every per-key default reads. func TestLoadConfigDestinations(t *testing.T) { useIsolatedConfig(t) dir := t.TempDir() p := writeConfig(t, dir, twoDestinations) // Several blocks, nothing selected, no default: refused, naming them. if _, err := loadConfig(p); err == nil || !strings.Contains(err.Error(), "exciting, tito") { t.Fatalf("ambiguous file = %v, want a refusal naming both destinations", err) } // Selected: the block wins, the top level fills what it leaves out, // and preferences stay top-level. selectDestination(t, "exciting") cfg, err := loadConfig(p) if err != nil { t.Fatal(err) } if cfg.Console != "https://carlos.exciting.example" || cfg.App != "shop" || cfg.Artifact != "./bin/server" || cfg.UpdateCheck != "off" || cfg.Destination != "exciting" { t.Fatalf("exciting = %+v", cfg) } selectDestination(t, "tito") if cfg, _ = loadConfig(p); cfg.Artifact != "./dist" || cfg.App != "shop-tito" { t.Fatalf("tito's own artifact must beat the top level: %+v", cfg) } // Selected but not defined here: this file describes other stacks, so // only its preferences survive. selectDestination(t, "staging") if cfg, err = loadConfig(p); err != nil || cfg.Console != "" || cfg.App != "" || cfg.Artifact != "" || cfg.UpdateCheck != "off" || cfg.Destination != "" { t.Fatalf("undefined here = %+v, %v; want preferences only", cfg, err) } // Same for a flat file with no destinations at all. flat := writeConfig(t, t.TempDir(), `{"console": "https://flat.example", "app": "x"}`) if cfg, err = loadConfig(flat); err != nil || cfg.Console != "" || cfg.App != "" { t.Fatalf("flat file under a selection = %+v, %v; want nothing", cfg, err) } // Nothing selected: the file's default. selectDestination(t, "") var withDefault map[string]any json.Unmarshal([]byte(twoDestinations), &withDefault) withDefault["default"] = "tito" body, _ := json.Marshal(withDefault) os.WriteFile(p, body, 0o644) if cfg, err = loadConfig(p); err != nil || cfg.Destination != "tito" || cfg.Console != "https://tito.example" { t.Fatalf("default = %+v, %v", cfg, err) } // ...which the flag overrides. selectDestination(t, "exciting") if cfg, _ = loadConfig(p); cfg.Destination != "exciting" { t.Fatalf("flag over default = %+v", cfg) } // A default naming no block is an error, not a fall-through. selectDestination(t, "") withDefault["default"] = "tit" body, _ = json.Marshal(withDefault) os.WriteFile(p, body, 0o644) if _, err = loadConfig(p); err == nil || !strings.Contains(err.Error(), `"tit"`) { t.Fatalf("bad default = %v, want an error naming it", err) } // Exactly one block needs no default. os.WriteFile(p, []byte(`{"destinations": {"only": {"console": "https://only.example"}}}`), 0o644) if cfg, err = loadConfig(p); err != nil || cfg.Destination != "only" || cfg.Console != "https://only.example" { t.Fatalf("single block = %+v, %v", cfg, err) } // A block's console is validated like a top-level one. os.WriteFile(p, []byte(`{"destinations": {"bad": {"console": "ftp://nope"}}}`), 0o644) if _, err = loadConfig(p); err == nil || !strings.Contains(err.Error(), p) { t.Fatalf("bad block console = %v, want an error naming %s", err, p) } } // A selected destination that no file defines is refused by the walk — // never "no default", which would fall through to whatever single // credential this machine holds. The walk still finds a destination // defined only in the global file past a project file that lacks it. func TestWalkRefusesUndefinedDestination(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("CARLOS_CONFIG", "") t.Setenv("CARLOS_CONSOLE", "") t.Setenv("CARLOS_DESTINATION", "") project := filepath.Join(home, "project") writeConfig(t, project, `{"destinations": {"tito": {"console": "https://tito.example"}}}`) t.Chdir(project) selectDestination(t, "titi") if _, _, err := configDefault(); err == nil || !strings.Contains(err.Error(), `"titi"`) { t.Fatalf("typo'd destination = %v, want a refusal naming it", err) } // The environment variable selects too. selectDestination(t, "") t.Setenv("CARLOS_DESTINATION", "titi") if _, _, err := configDefault(); err == nil { t.Fatal("CARLOS_DESTINATION typo must be refused too") } writeConfig(t, home, `{"destinations": {"titi": {"console": "https://global.example"}}}`) if c, _, err := configDefault(); err != nil || c != "https://global.example" { t.Fatalf("global-only destination = %q %v", c, err) } t.Setenv("CARLOS_DESTINATION", "") selectDestination(t, "tito") if c, _, err := configDefault(); err != nil || c != "https://tito.example" { t.Fatalf("project destination = %q %v", c, err) } } // Saves land where the read resolved: the selected block, else the // default/only block, else the top level. func TestWriteConfigKeysIntoDestination(t *testing.T) { useIsolatedConfig(t) p := writeConfig(t, t.TempDir(), twoDestinations) read := func() map[string]any { t.Helper() raw, _ := os.ReadFile(p) var m map[string]any if err := json.Unmarshal(raw, &m); err != nil { t.Fatal(err) } return m } block := func(m map[string]any, name string) map[string]any { return m["destinations"].(map[string]any)[name].(map[string]any) } if err := writeConfigKeys(p, map[string]any{"account": "a"}, 0o755); err == nil { t.Fatal("several blocks and no selection must refuse the write") } selectDestination(t, "tito") if err := saveConfigAccount(p, "https://tito.example", "tito-acct", 0o755); err != nil { t.Fatal(err) } m := read() if block(m, "tito")["account"] != "tito-acct" || m["account"] != nil { t.Fatalf("selected write landed wrong: %v", m) } if block(m, "exciting")["app"] != "shop" || m["update_check"] != "off" { t.Fatalf("unrelated keys lost: %v", m) } // A new name creates its block. selectDestination(t, "staging") if err := writeConfigKeys(p, map[string]any{"app": "shop-staging"}, 0o755); err != nil { t.Fatal(err) } if block(read(), "staging")["app"] != "shop-staging" { t.Fatal("new destination block not created") } // No selection, a default: into the default. selectDestination(t, "") m = read() m["default"] = "exciting" body, _ := json.Marshal(m) os.WriteFile(p, body, 0o644) if err := writeConfigKeys(p, map[string]any{"account": "ex"}, 0o755); err != nil { t.Fatal(err) } if block(read(), "exciting")["account"] != "ex" { t.Fatal("default-block write landed wrong") } // A flat file stays flat. flat := writeConfig(t, t.TempDir(), `{"console": "https://flat.example"}`) if err := writeConfigKeys(flat, map[string]any{"app": "x"}, 0o755); err != nil { t.Fatal(err) } raw, _ := os.ReadFile(flat) if strings.Contains(string(raw), "destinations") || !strings.Contains(string(raw), `"app": "x"`) { t.Fatalf("flat write = %s", raw) } } // -d and --destination both select, and a second flag set built later in // the same run (deploy re-running auth login) does not clear the choice. func TestDestinationFlag(t *testing.T) { for _, args := range [][]string{{"-d", "tito"}, {"--destination", "tito"}, {"-destination=tito"}, {"--d=tito"}} { selectDestination(t, "") fs := quietFlagSet("t") consoleOption(fs, "") if err := fs.Parse(args); err != nil { t.Fatalf("%v: %v", args, err) } if selectedDestination() != "tito" { t.Fatalf("%v: selected %q", args, selectedDestination()) } consoleOption(quietFlagSet("again"), "") if selectedDestination() != "tito" { t.Fatalf("%v: a later flag set cleared the destination", args) } } } // End to end through the real binary: two consoles, one checkout. -d picks // the console, the default answers a bare run, and the "using" line names // the destination. Each console refuses the deploy at its instance list // (none), which is enough to prove which one was asked. func TestDeployDestinationsSubprocess(t *testing.T) { bin := buildCarlos(t) servers := map[string]string{} for _, name := range []string{"exciting", "tito"} { servers[name] = zeroInstanceConsole(t, "acct-"+name, "app-"+name) } project := t.TempDir() cfg := map[string]any{ "default": "exciting", "kind": "binary", "artifact": "./bin/server", "destinations": map[string]any{ "exciting": map[string]any{"console": servers["exciting"], "account": "acct-exciting", "app": "app-exciting"}, "tito": map[string]any{"console": servers["tito"], "account": "acct-tito", "app": "app-tito"}, }, } body, _ := json.Marshal(cfg) writeConfig(t, project, string(body)) os.MkdirAll(filepath.Join(project, "bin"), 0o755) os.WriteFile(filepath.Join(project, "bin", "server"), []byte("bin"), 0o755) credPath := filepath.Join(t.TempDir(), "credentials") creds := `{"version":1,"credentials":[` + `{"console":"` + servers["exciting"] + `","address":"me@exciting","token":"t1"},` + `{"console":"` + servers["tito"] + `","address":"me@tito","token":"t2"}]}` os.WriteFile(credPath, []byte(creds), 0o600) run := func(args ...string) string { t.Helper() cmd := exec.Command(bin, append([]string{"deploy", "-version", "x1"}, args...)...) cmd.Env = carlosTestEnv(t, "CARLOS_CREDENTIALS="+credPath) cmd.Dir = project out, err := cmd.CombinedOutput() if err == nil { t.Fatalf("%v: no-instances fixture must refuse, got success:\n%s", args, out) } return string(out) } for _, c := range []struct { args []string want string }{ {nil, "exciting"}, {[]string{"-d", "tito"}, "tito"}, {[]string{"--destination", "exciting"}, "exciting"}, } { out := run(c.args...) if !strings.Contains(out, "using "+servers[c.want]+" as me@"+c.want+" (destination "+c.want+")") || !strings.Contains(out, "the "+c.want+" destination in") || !strings.Contains(out, "no instances") { t.Fatalf("%v: want destination %s, got:\n%s", c.args, c.want, out) } } if out := run("-d", "staging"); !strings.Contains(out, `destination "staging"`) { t.Fatalf("undefined destination = %s", out) } } // zeroInstanceConsole is a fake console that knows one account owning one // app with no instances — deploy's first refusal after resolution. func zeroInstanceConsole(t *testing.T, account, app string) string { t.Helper() mux := http.NewServeMux() mux.HandleFunc("GET /api/cli/apps", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, `{"accounts":[{"sqid":"s-%s","name":%q,"apps":[%q]}]}`, account, account, app) }) mux.HandleFunc("GET /api/cli/apps/", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"instances":[]}`)) }) ts := httptest.NewServer(mux) t.Cleanup(ts.Close) return ts.URL }