| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "net/http" |
| 7 | "net/http/httptest" |
| 8 | "os" |
| 9 | "os/exec" |
| 10 | "path/filepath" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | ) |
| 14 | |
| 15 | // selectDestination sets --destination for one test, and puts it back. |
| 16 | func selectDestination(t *testing.T, name string) { |
| 17 | t.Helper() |
| 18 | prev := destinationFlag |
| 19 | destinationFlag = name |
| 20 | t.Cleanup(func() { destinationFlag = prev }) |
| 21 | } |
| 22 | |
| 23 | const twoDestinations = `{ |
| 24 | "artifact": "./bin/server", |
| 25 | "update_check": "off", |
| 26 | "destinations": { |
| 27 | "exciting": {"console": "https://carlos.exciting.example/", "app": "shop"}, |
| 28 | "tito": {"console": "https://tito.example", "app": "shop-tito", "artifact": "./dist"} |
| 29 | } |
| 30 | }` |
| 31 | |
| 32 | // Selection, the file's default, the only block, and the refusals — each |
| 33 | // resolved through loadConfig, which every per-key default reads. |
| 34 | func TestLoadConfigDestinations(t *testing.T) { |
| 35 | useIsolatedConfig(t) |
| 36 | dir := t.TempDir() |
| 37 | p := writeConfig(t, dir, twoDestinations) |
| 38 | |
| 39 | // Several blocks, nothing selected, no default: refused, naming them. |
| 40 | if _, err := loadConfig(p); err == nil || !strings.Contains(err.Error(), "exciting, tito") { |
| 41 | t.Fatalf("ambiguous file = %v, want a refusal naming both destinations", err) |
| 42 | } |
| 43 | |
| 44 | // Selected: the block wins, the top level fills what it leaves out, |
| 45 | // and preferences stay top-level. |
| 46 | selectDestination(t, "exciting") |
| 47 | cfg, err := loadConfig(p) |
| 48 | if err != nil { |
| 49 | t.Fatal(err) |
| 50 | } |
| 51 | if cfg.Console != "https://carlos.exciting.example" || cfg.App != "shop" || |
| 52 | cfg.Artifact != "./bin/server" || cfg.UpdateCheck != "off" || cfg.Destination != "exciting" { |
| 53 | t.Fatalf("exciting = %+v", cfg) |
| 54 | } |
| 55 | selectDestination(t, "tito") |
| 56 | if cfg, _ = loadConfig(p); cfg.Artifact != "./dist" || cfg.App != "shop-tito" { |
| 57 | t.Fatalf("tito's own artifact must beat the top level: %+v", cfg) |
| 58 | } |
| 59 | |
| 60 | // Selected but not defined here: this file describes other stacks, so |
| 61 | // only its preferences survive. |
| 62 | selectDestination(t, "staging") |
| 63 | if cfg, err = loadConfig(p); err != nil || cfg.Console != "" || cfg.App != "" || |
| 64 | cfg.Artifact != "" || cfg.UpdateCheck != "off" || cfg.Destination != "" { |
| 65 | t.Fatalf("undefined here = %+v, %v; want preferences only", cfg, err) |
| 66 | } |
| 67 | // Same for a flat file with no destinations at all. |
| 68 | flat := writeConfig(t, t.TempDir(), `{"console": "https://flat.example", "app": "x"}`) |
| 69 | if cfg, err = loadConfig(flat); err != nil || cfg.Console != "" || cfg.App != "" { |
| 70 | t.Fatalf("flat file under a selection = %+v, %v; want nothing", cfg, err) |
| 71 | } |
| 72 | |
| 73 | // Nothing selected: the file's default. |
| 74 | selectDestination(t, "") |
| 75 | var withDefault map[string]any |
| 76 | json.Unmarshal([]byte(twoDestinations), &withDefault) |
| 77 | withDefault["default"] = "tito" |
| 78 | body, _ := json.Marshal(withDefault) |
| 79 | os.WriteFile(p, body, 0o644) |
| 80 | if cfg, err = loadConfig(p); err != nil || cfg.Destination != "tito" || cfg.Console != "https://tito.example" { |
| 81 | t.Fatalf("default = %+v, %v", cfg, err) |
| 82 | } |
| 83 | // ...which the flag overrides. |
| 84 | selectDestination(t, "exciting") |
| 85 | if cfg, _ = loadConfig(p); cfg.Destination != "exciting" { |
| 86 | t.Fatalf("flag over default = %+v", cfg) |
| 87 | } |
| 88 | // A default naming no block is an error, not a fall-through. |
| 89 | selectDestination(t, "") |
| 90 | withDefault["default"] = "tit" |
| 91 | body, _ = json.Marshal(withDefault) |
| 92 | os.WriteFile(p, body, 0o644) |
| 93 | if _, err = loadConfig(p); err == nil || !strings.Contains(err.Error(), `"tit"`) { |
| 94 | t.Fatalf("bad default = %v, want an error naming it", err) |
| 95 | } |
| 96 | |
| 97 | // Exactly one block needs no default. |
| 98 | os.WriteFile(p, []byte(`{"destinations": {"only": {"console": "https://only.example"}}}`), 0o644) |
| 99 | if cfg, err = loadConfig(p); err != nil || cfg.Destination != "only" || cfg.Console != "https://only.example" { |
| 100 | t.Fatalf("single block = %+v, %v", cfg, err) |
| 101 | } |
| 102 | |
| 103 | // A block's console is validated like a top-level one. |
| 104 | os.WriteFile(p, []byte(`{"destinations": {"bad": {"console": "ftp://nope"}}}`), 0o644) |
| 105 | if _, err = loadConfig(p); err == nil || !strings.Contains(err.Error(), p) { |
| 106 | t.Fatalf("bad block console = %v, want an error naming %s", err, p) |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | // A selected destination that no file defines is refused by the walk — |
| 111 | // never "no default", which would fall through to whatever single |
| 112 | // credential this machine holds. The walk still finds a destination |
| 113 | // defined only in the global file past a project file that lacks it. |
| 114 | func TestWalkRefusesUndefinedDestination(t *testing.T) { |
| 115 | home := t.TempDir() |
| 116 | t.Setenv("HOME", home) |
| 117 | t.Setenv("CARLOS_CONFIG", "") |
| 118 | t.Setenv("CARLOS_CONSOLE", "") |
| 119 | t.Setenv("CARLOS_DESTINATION", "") |
| 120 | project := filepath.Join(home, "project") |
| 121 | writeConfig(t, project, `{"destinations": {"tito": {"console": "https://tito.example"}}}`) |
| 122 | t.Chdir(project) |
| 123 | |
| 124 | selectDestination(t, "titi") |
| 125 | if _, _, err := configDefault(); err == nil || !strings.Contains(err.Error(), `"titi"`) { |
| 126 | t.Fatalf("typo'd destination = %v, want a refusal naming it", err) |
| 127 | } |
| 128 | // The environment variable selects too. |
| 129 | selectDestination(t, "") |
| 130 | t.Setenv("CARLOS_DESTINATION", "titi") |
| 131 | if _, _, err := configDefault(); err == nil { |
| 132 | t.Fatal("CARLOS_DESTINATION typo must be refused too") |
| 133 | } |
| 134 | |
| 135 | writeConfig(t, home, `{"destinations": {"titi": {"console": "https://global.example"}}}`) |
| 136 | if c, _, err := configDefault(); err != nil || c != "https://global.example" { |
| 137 | t.Fatalf("global-only destination = %q %v", c, err) |
| 138 | } |
| 139 | t.Setenv("CARLOS_DESTINATION", "") |
| 140 | selectDestination(t, "tito") |
| 141 | if c, _, err := configDefault(); err != nil || c != "https://tito.example" { |
| 142 | t.Fatalf("project destination = %q %v", c, err) |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | // Saves land where the read resolved: the selected block, else the |
| 147 | // default/only block, else the top level. |
| 148 | func TestWriteConfigKeysIntoDestination(t *testing.T) { |
| 149 | useIsolatedConfig(t) |
| 150 | p := writeConfig(t, t.TempDir(), twoDestinations) |
| 151 | read := func() map[string]any { |
| 152 | t.Helper() |
| 153 | raw, _ := os.ReadFile(p) |
| 154 | var m map[string]any |
| 155 | if err := json.Unmarshal(raw, &m); err != nil { |
| 156 | t.Fatal(err) |
| 157 | } |
| 158 | return m |
| 159 | } |
| 160 | block := func(m map[string]any, name string) map[string]any { |
| 161 | return m["destinations"].(map[string]any)[name].(map[string]any) |
| 162 | } |
| 163 | |
| 164 | if err := writeConfigKeys(p, map[string]any{"account": "a"}, 0o755); err == nil { |
| 165 | t.Fatal("several blocks and no selection must refuse the write") |
| 166 | } |
| 167 | |
| 168 | selectDestination(t, "tito") |
| 169 | if err := saveConfigAccount(p, "https://tito.example", "tito-acct", 0o755); err != nil { |
| 170 | t.Fatal(err) |
| 171 | } |
| 172 | m := read() |
| 173 | if block(m, "tito")["account"] != "tito-acct" || m["account"] != nil { |
| 174 | t.Fatalf("selected write landed wrong: %v", m) |
| 175 | } |
| 176 | if block(m, "exciting")["app"] != "shop" || m["update_check"] != "off" { |
| 177 | t.Fatalf("unrelated keys lost: %v", m) |
| 178 | } |
| 179 | |
| 180 | // A new name creates its block. |
| 181 | selectDestination(t, "staging") |
| 182 | if err := writeConfigKeys(p, map[string]any{"app": "shop-staging"}, 0o755); err != nil { |
| 183 | t.Fatal(err) |
| 184 | } |
| 185 | if block(read(), "staging")["app"] != "shop-staging" { |
| 186 | t.Fatal("new destination block not created") |
| 187 | } |
| 188 | |
| 189 | // No selection, a default: into the default. |
| 190 | selectDestination(t, "") |
| 191 | m = read() |
| 192 | m["default"] = "exciting" |
| 193 | body, _ := json.Marshal(m) |
| 194 | os.WriteFile(p, body, 0o644) |
| 195 | if err := writeConfigKeys(p, map[string]any{"account": "ex"}, 0o755); err != nil { |
| 196 | t.Fatal(err) |
| 197 | } |
| 198 | if block(read(), "exciting")["account"] != "ex" { |
| 199 | t.Fatal("default-block write landed wrong") |
| 200 | } |
| 201 | |
| 202 | // A flat file stays flat. |
| 203 | flat := writeConfig(t, t.TempDir(), `{"console": "https://flat.example"}`) |
| 204 | if err := writeConfigKeys(flat, map[string]any{"app": "x"}, 0o755); err != nil { |
| 205 | t.Fatal(err) |
| 206 | } |
| 207 | raw, _ := os.ReadFile(flat) |
| 208 | if strings.Contains(string(raw), "destinations") || !strings.Contains(string(raw), `"app": "x"`) { |
| 209 | t.Fatalf("flat write = %s", raw) |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | // -d and --destination both select, and a second flag set built later in |
| 214 | // the same run (deploy re-running auth login) does not clear the choice. |
| 215 | func TestDestinationFlag(t *testing.T) { |
| 216 | for _, args := range [][]string{{"-d", "tito"}, {"--destination", "tito"}, {"-destination=tito"}, {"--d=tito"}} { |
| 217 | selectDestination(t, "") |
| 218 | fs := quietFlagSet("t") |
| 219 | consoleOption(fs, "") |
| 220 | if err := fs.Parse(args); err != nil { |
| 221 | t.Fatalf("%v: %v", args, err) |
| 222 | } |
| 223 | if selectedDestination() != "tito" { |
| 224 | t.Fatalf("%v: selected %q", args, selectedDestination()) |
| 225 | } |
| 226 | consoleOption(quietFlagSet("again"), "") |
| 227 | if selectedDestination() != "tito" { |
| 228 | t.Fatalf("%v: a later flag set cleared the destination", args) |
| 229 | } |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | // End to end through the real binary: two consoles, one checkout. -d picks |
| 234 | // the console, the default answers a bare run, and the "using" line names |
| 235 | // the destination. Each console refuses the deploy at its instance list |
| 236 | // (none), which is enough to prove which one was asked. |
| 237 | func TestDeployDestinationsSubprocess(t *testing.T) { |
| 238 | bin := buildCarlos(t) |
| 239 | servers := map[string]string{} |
| 240 | for _, name := range []string{"exciting", "tito"} { |
| 241 | servers[name] = zeroInstanceConsole(t, "acct-"+name, "app-"+name) |
| 242 | } |
| 243 | project := t.TempDir() |
| 244 | cfg := map[string]any{ |
| 245 | "default": "exciting", |
| 246 | "kind": "binary", |
| 247 | "artifact": "./bin/server", |
| 248 | "destinations": map[string]any{ |
| 249 | "exciting": map[string]any{"console": servers["exciting"], "account": "acct-exciting", "app": "app-exciting"}, |
| 250 | "tito": map[string]any{"console": servers["tito"], "account": "acct-tito", "app": "app-tito"}, |
| 251 | }, |
| 252 | } |
| 253 | body, _ := json.Marshal(cfg) |
| 254 | writeConfig(t, project, string(body)) |
| 255 | os.MkdirAll(filepath.Join(project, "bin"), 0o755) |
| 256 | os.WriteFile(filepath.Join(project, "bin", "server"), []byte("bin"), 0o755) |
| 257 | credPath := filepath.Join(t.TempDir(), "credentials") |
| 258 | creds := `{"version":1,"credentials":[` + |
| 259 | `{"console":"` + servers["exciting"] + `","address":"me@exciting","token":"t1"},` + |
| 260 | `{"console":"` + servers["tito"] + `","address":"me@tito","token":"t2"}]}` |
| 261 | os.WriteFile(credPath, []byte(creds), 0o600) |
| 262 | |
| 263 | run := func(args ...string) string { |
| 264 | t.Helper() |
| 265 | cmd := exec.Command(bin, append([]string{"deploy", "-version", "x1"}, args...)...) |
| 266 | cmd.Env = carlosTestEnv(t, "CARLOS_CREDENTIALS="+credPath) |
| 267 | cmd.Dir = project |
| 268 | out, err := cmd.CombinedOutput() |
| 269 | if err == nil { |
| 270 | t.Fatalf("%v: no-instances fixture must refuse, got success:\n%s", args, out) |
| 271 | } |
| 272 | return string(out) |
| 273 | } |
| 274 | for _, c := range []struct { |
| 275 | args []string |
| 276 | want string |
| 277 | }{ |
| 278 | {nil, "exciting"}, |
| 279 | {[]string{"-d", "tito"}, "tito"}, |
| 280 | {[]string{"--destination", "exciting"}, "exciting"}, |
| 281 | } { |
| 282 | out := run(c.args...) |
| 283 | if !strings.Contains(out, "using "+servers[c.want]+" as me@"+c.want+" (destination "+c.want+")") || |
| 284 | !strings.Contains(out, "the "+c.want+" destination in") || |
| 285 | !strings.Contains(out, "no instances") { |
| 286 | t.Fatalf("%v: want destination %s, got:\n%s", c.args, c.want, out) |
| 287 | } |
| 288 | } |
| 289 | if out := run("-d", "staging"); !strings.Contains(out, `destination "staging"`) { |
| 290 | t.Fatalf("undefined destination = %s", out) |
| 291 | } |
| 292 | } |
| 293 | |
| 294 | // zeroInstanceConsole is a fake console that knows one account owning one |
| 295 | // app with no instances — deploy's first refusal after resolution. |
| 296 | func zeroInstanceConsole(t *testing.T, account, app string) string { |
| 297 | t.Helper() |
| 298 | mux := http.NewServeMux() |
| 299 | mux.HandleFunc("GET /api/cli/apps", func(w http.ResponseWriter, r *http.Request) { |
| 300 | fmt.Fprintf(w, `{"accounts":[{"sqid":"s-%s","name":%q,"apps":[%q]}]}`, account, account, app) |
| 301 | }) |
| 302 | mux.HandleFunc("GET /api/cli/apps/", func(w http.ResponseWriter, r *http.Request) { |
| 303 | w.Write([]byte(`{"instances":[]}`)) |
| 304 | }) |
| 305 | ts := httptest.NewServer(mux) |
| 306 | t.Cleanup(ts.Close) |
| 307 | return ts.URL |
| 308 | } |
| 309 | |