| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "flag" |
| 8 | "net/http" |
| 9 | "net/http/httptest" |
| 10 | "os" |
| 11 | "os/exec" |
| 12 | "path/filepath" |
| 13 | "strings" |
| 14 | "testing" |
| 15 | ) |
| 16 | |
| 17 | func TestFlagWasSet(t *testing.T) { |
| 18 | fs := flag.NewFlagSet("t", flag.ContinueOnError) |
| 19 | fs.String("kind", "binary", "") |
| 20 | fs.String("target", "linux-arm64", "") |
| 21 | if err := fs.Parse([]string{"-kind", "static"}); err != nil { |
| 22 | t.Fatal(err) |
| 23 | } |
| 24 | if !flagWasSet(fs, "kind") || flagWasSet(fs, "target") { |
| 25 | t.Fatal("parsed --kind must report set; untyped --target must not") |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | func TestDeployArtifactWizard(t *testing.T) { |
| 30 | useTempCredentials(t) |
| 31 | captureStdout(t) |
| 32 | |
| 33 | // A lone conventional dir pre-fills; an empty answer takes it. |
| 34 | if err := os.MkdirAll("public", 0o755); err != nil { |
| 35 | t.Fatal(err) |
| 36 | } |
| 37 | swapChooserIn(t, "\n") |
| 38 | artifact, kind, _, err := deployArtifactWizard() |
| 39 | if err != nil || artifact != "./public" || kind != "static" { |
| 40 | t.Fatalf("suggested dir = %q %q %v", artifact, kind, err) |
| 41 | } |
| 42 | |
| 43 | // Two conventional dirs: no suggestion, the empty answer is a miss and |
| 44 | // a real answer on the retry lands. |
| 45 | if err := os.MkdirAll("dist", 0o755); err != nil { |
| 46 | t.Fatal(err) |
| 47 | } |
| 48 | swapChooserIn(t, "\npublic\n") |
| 49 | if artifact, kind, _, err = deployArtifactWizard(); err != nil || artifact != "public" || kind != "static" { |
| 50 | t.Fatalf("ambiguous dirs = %q %q %v", artifact, kind, err) |
| 51 | } |
| 52 | |
| 53 | // A regular file is a binary, target pre-filled. |
| 54 | if err := os.WriteFile("server", []byte("bin"), 0o755); err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | swapChooserIn(t, "server\n\n") |
| 58 | artifact, kind, target, err := deployArtifactWizard() |
| 59 | if err != nil || artifact != "server" || kind != "binary" || target != "linux-arm64" { |
| 60 | t.Fatalf("binary = %q %q %q %v", artifact, kind, target, err) |
| 61 | } |
| 62 | |
| 63 | // A missing path re-prompts once, then the wizard gives up with the |
| 64 | // build-first message. |
| 65 | swapChooserIn(t, "nope\nalso-nope\n") |
| 66 | if _, _, _, err = deployArtifactWizard(); err == nil || |
| 67 | !strings.Contains(err.Error(), "nothing to ship") { |
| 68 | t.Fatalf("two misses = %v", err) |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | // swapChooserIn points the shared prompt reader at a script for one test. |
| 73 | func swapChooserIn(t *testing.T, script string) { |
| 74 | t.Helper() |
| 75 | old := chooserIn |
| 76 | chooserIn = strings.NewReader(script) |
| 77 | t.Cleanup(func() { chooserIn = old }) |
| 78 | } |
| 79 | |
| 80 | func TestOfferCreateAppUsesDirectoryName(t *testing.T) { |
| 81 | useTempCredentials(t) |
| 82 | captureStdout(t) |
| 83 | forceTerminal(t, true) |
| 84 | var gotName string |
| 85 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 86 | var in struct { |
| 87 | Name string `json:"name"` |
| 88 | } |
| 89 | json.NewDecoder(r.Body).Decode(&in) |
| 90 | gotName = in.Name |
| 91 | json.NewEncoder(w).Encode(map[string]any{"app": in.Name, "account": "bdk", |
| 92 | "url": "https://x/a/bdk/" + in.Name}) |
| 93 | })) |
| 94 | t.Cleanup(ts.Close) |
| 95 | |
| 96 | // cwd's base name is the suggestion — make it a valid app name. |
| 97 | dir := filepath.Join(t.TempDir(), "taskmonger") |
| 98 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 99 | t.Fatal(err) |
| 100 | } |
| 101 | t.Chdir(dir) |
| 102 | swapChooserIn(t, "y\n") |
| 103 | s := newTestSession(ts.URL) |
| 104 | app := offerCreateApp(context.Background(), s, apiAcct{Seg: "bdk", Exact: true}, |
| 105 | &noAppsError{Account: "tom@keymail.dev"}, "") |
| 106 | if app != "taskmonger" || gotName != "taskmonger" { |
| 107 | t.Fatalf("created %q (server saw %q), want the directory's name", app, gotName) |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | func TestProjectArtifactContainment(t *testing.T) { |
| 112 | root := t.TempDir() |
| 113 | dsrc := filepath.Join(root, ".carlos", "config") |
| 114 | if err := os.MkdirAll(filepath.Join(root, "public"), 0o755); err != nil { |
| 115 | t.Fatal(err) |
| 116 | } |
| 117 | |
| 118 | // Inside the tree: resolved against the config's root, so a saved |
| 119 | // "./public" works from any subdirectory. |
| 120 | got, err := projectArtifact(dsrc, "./public") |
| 121 | if err != nil || got != filepath.Join(root, "public") { |
| 122 | t.Fatalf("in-tree = %q, %v", got, err) |
| 123 | } |
| 124 | |
| 125 | // The escape shapes a hostile committed config would use: every one a |
| 126 | // refusal, never a pack (a config artifact of ../../.carlos would tar |
| 127 | // the operator's credentials and PUBLISH them). |
| 128 | for _, bad := range []string{"/etc", "../outside", "../../.carlos", "public/../../x"} { |
| 129 | if _, err := projectArtifact(dsrc, bad); err == nil { |
| 130 | t.Errorf("projectArtifact(%q) accepted an escape", bad) |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | // A symlink inside the tree pointing outside it is the same escape. |
| 135 | outside := t.TempDir() |
| 136 | if err := os.Symlink(outside, filepath.Join(root, "site")); err != nil { |
| 137 | t.Fatal(err) |
| 138 | } |
| 139 | if _, err := projectArtifact(dsrc, "site"); err == nil { |
| 140 | t.Error("a symlink escaping the tree was accepted") |
| 141 | } |
| 142 | |
| 143 | // A stale saved path is an error naming the file to edit. |
| 144 | if _, err := projectArtifact(dsrc, "gone"); err == nil || |
| 145 | !strings.Contains(err.Error(), ".carlos/config") { |
| 146 | t.Fatalf("missing artifact = %v, want an error naming the config", err) |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | func TestDeployTargetsAmbiguityIsTyped(t *testing.T) { |
| 151 | // No instances at all: a hard refusal that merely MENTIONS -channel — |
| 152 | // the chooser must never answer it (it once converted this refusal |
| 153 | // into a ship+promote). |
| 154 | _, _, _, err := deployTargets(nil, "binary", "", "", func() (apiChannelsResult, error) { return apiChannelsResult{}, nil }) |
| 155 | if err == nil || errors.Is(err, errAmbiguousChannel) { |
| 156 | t.Fatalf("no-instances = %v, want a NON-ambiguity refusal", err) |
| 157 | } |
| 158 | |
| 159 | // Instances split across channels: genuine ambiguity, typed. |
| 160 | recs := []apiInstanceRecord{{Host: "a.x", Channel: "canary/a"}, {Host: "b.x", Channel: "canary/b"}} |
| 161 | _, _, _, err = deployTargets(recs, "binary", "", "", func() (apiChannelsResult, error) { return apiChannelsResult{}, nil }) |
| 162 | if !errors.Is(err, errAmbiguousChannel) { |
| 163 | t.Fatalf("split channels = %v, want errAmbiguousChannel", err) |
| 164 | } |
| 165 | |
| 166 | // Static app, no instances, several channels: ambiguity, typed. |
| 167 | chans := apiChannelsResult{Channels: []apiChannel{{Channel: "canary/site"}, {Channel: "stable"}}} |
| 168 | _, _, _, err = deployTargets(nil, "static", "", "h.example", func() (apiChannelsResult, error) { return chans, nil }) |
| 169 | if !errors.Is(err, errAmbiguousChannel) { |
| 170 | t.Fatalf("static several channels = %v, want errAmbiguousChannel", err) |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | func TestChooseDeployChannel(t *testing.T) { |
| 175 | useTempCredentials(t) |
| 176 | captureStdout(t) |
| 177 | serve := func(body string) *apiSession { |
| 178 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 179 | w.Write([]byte(body)) |
| 180 | })) |
| 181 | t.Cleanup(ts.Close) |
| 182 | return newTestSession(ts.URL) |
| 183 | } |
| 184 | |
| 185 | // Fewer than two CLEAN channels: no chooser — back to the refusal. |
| 186 | s := serve(`{"channels":[{"channel":"canary/a"},{"channel":"bad","error":"unsigned"}]}`) |
| 187 | if _, ok := chooseDeployChannel(context.Background(), s, apiAcct{Seg: "bdk"}, "shop"); ok { |
| 188 | t.Fatal("one clean channel must not offer a choice") |
| 189 | } |
| 190 | |
| 191 | // Two clean ones: numbered pick. |
| 192 | s = serve(`{"channels":[{"channel":"canary/a"},{"channel":"canary/b"}]}`) |
| 193 | swapChooserIn(t, "2\n") |
| 194 | picked, ok := chooseDeployChannel(context.Background(), s, apiAcct{Seg: "bdk"}, "shop") |
| 195 | if !ok || picked != "canary/b" { |
| 196 | t.Fatalf("pick = %q %v", picked, ok) |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | func TestResolvedAppExplicitFlagBypassesTheFetch(t *testing.T) { |
| 201 | useTempCredentials(t) |
| 202 | captureStdout(t) |
| 203 | s, hits := accountsConsole(t, http.StatusOK, `{"accounts":[]}`) |
| 204 | if got := resolvedApp(s, apiAcct{Seg: "bdk", Exact: true}, "shop", "usage"); got != "shop" { |
| 205 | t.Fatalf("explicit --app = %q", got) |
| 206 | } |
| 207 | if *hits != 0 { |
| 208 | t.Fatalf("explicit --app must never consult the endpoint; hits = %d", *hits) |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | // The second-run promise, end to end as a real subprocess: a checkout with |
| 213 | // saved deploy defaults runs `carlos deploy` with NO arguments and NO |
| 214 | // prompts (non-TTY here, so any prompt would hang or misroute) — account, |
| 215 | // app, kind and artifact all come from .carlos/config, proven by the muted |
| 216 | // "defaults from" line and by reaching deploy's own no-instances refusal |
| 217 | // (the first thing after resolution for this fixture) rather than any |
| 218 | // usage or login error. |
| 219 | func TestZeroArgDeployRunsFromSavedDefaults(t *testing.T) { |
| 220 | bin := buildCarlos(t) |
| 221 | mux := http.NewServeMux() |
| 222 | mux.HandleFunc("GET /api/cli/apps", func(w http.ResponseWriter, r *http.Request) { |
| 223 | w.Write([]byte(`{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev","apps":["taskmonger"]}]}`)) |
| 224 | }) |
| 225 | mux.HandleFunc("GET /api/cli/apps/", func(w http.ResponseWriter, r *http.Request) { |
| 226 | w.Write([]byte(`{"instances":[]}`)) // instance list: none |
| 227 | }) |
| 228 | ts := httptest.NewServer(mux) |
| 229 | t.Cleanup(ts.Close) |
| 230 | |
| 231 | project := t.TempDir() |
| 232 | if err := os.MkdirAll(filepath.Join(project, ".carlos"), 0o755); err != nil { |
| 233 | t.Fatal(err) |
| 234 | } |
| 235 | cfg := `{"console":"` + ts.URL + `","account":"tom@keymail.dev","app":"taskmonger","kind":"binary","artifact":"./bin/server","target":"linux-arm64"}` |
| 236 | if err := os.WriteFile(filepath.Join(project, ".carlos", "config"), []byte(cfg), 0o644); err != nil { |
| 237 | t.Fatal(err) |
| 238 | } |
| 239 | if err := os.MkdirAll(filepath.Join(project, "bin"), 0o755); err != nil { |
| 240 | t.Fatal(err) |
| 241 | } |
| 242 | if err := os.WriteFile(filepath.Join(project, "bin", "server"), []byte("bin"), 0o755); err != nil { |
| 243 | t.Fatal(err) |
| 244 | } |
| 245 | credPath := filepath.Join(t.TempDir(), "credentials") |
| 246 | creds := `{"version":1,"credentials":[{"console":"` + ts.URL + `","address":"tom@keymail.dev","token":"tok"}]}` |
| 247 | if err := os.WriteFile(credPath, []byte(creds), 0o600); err != nil { |
| 248 | t.Fatal(err) |
| 249 | } |
| 250 | |
| 251 | cmd := exec.Command(bin, "deploy", "-version", "x1") // scratch dir: no git for gitShortHead |
| 252 | cmd.Env = carlosTestEnv(t, "CARLOS_CREDENTIALS="+credPath) |
| 253 | cmd.Dir = project |
| 254 | out, err := cmd.CombinedOutput() |
| 255 | if err == nil { |
| 256 | t.Fatalf("no-instances fixture must refuse, got success:\n%s", out) |
| 257 | } |
| 258 | if !strings.Contains(string(out), "the defaults from") || |
| 259 | !strings.Contains(string(out), "no instances — create one") { |
| 260 | t.Fatalf("zero-arg deploy from saved defaults = %q\nwant the defaults line and the no-instances refusal", out) |
| 261 | } |
| 262 | for _, never := range []string{"[y/N]", "usage:", "not logged in", "artifact to deploy"} { |
| 263 | if strings.Contains(string(out), never) { |
| 264 | t.Fatalf("zero-question run printed %q:\n%s", never, out) |
| 265 | } |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | // And the same shape with NOTHING saved and no terminal: the one error that |
| 270 | // names both remedies, before any network prompt could hang a script. |
| 271 | func TestZeroArgDeployNonTTYWithoutDefaults(t *testing.T) { |
| 272 | bin := buildCarlos(t) |
| 273 | cmd := exec.Command(bin, "deploy", "-version", "x1") // scratch dir: no git for gitShortHead |
| 274 | cmd.Env = carlosTestEnv(t) |
| 275 | cmd.Dir = t.TempDir() |
| 276 | out, err := cmd.CombinedOutput() |
| 277 | if err == nil { |
| 278 | t.Fatalf("bare deploy with nothing configured succeeded:\n%s", out) |
| 279 | } |
| 280 | if !strings.Contains(string(out), "no deployment target configured") { |
| 281 | t.Fatalf("bare deploy = %q, want the no-target refusal (the session is the first blocker)", out) |
| 282 | } |
| 283 | } |
| 284 | |
| 285 | func TestAccountName(t *testing.T) { |
| 286 | s := &apiSession{accts: []apiAccountEntry{{Name: "tom@keymail.dev", Sqid: "bdk"}}} |
| 287 | if got := s.accountName(apiAcct{Seg: "bdk", Exact: true}); got != "tom@keymail.dev" { |
| 288 | t.Fatalf("by sqid = %q", got) |
| 289 | } |
| 290 | if got := s.accountName(apiAcct{Seg: "acme"}); got != "acme" { |
| 291 | t.Fatalf("unknown segment must pass through, got %q", got) |
| 292 | } |
| 293 | } |
| 294 | |