package main import ( "context" "encoding/json" "errors" "flag" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "strings" "testing" ) func TestFlagWasSet(t *testing.T) { fs := flag.NewFlagSet("t", flag.ContinueOnError) fs.String("kind", "binary", "") fs.String("target", "linux-arm64", "") if err := fs.Parse([]string{"-kind", "static"}); err != nil { t.Fatal(err) } if !flagWasSet(fs, "kind") || flagWasSet(fs, "target") { t.Fatal("parsed --kind must report set; untyped --target must not") } } func TestDeployArtifactWizard(t *testing.T) { useTempCredentials(t) captureStdout(t) // A lone conventional dir pre-fills; an empty answer takes it. if err := os.MkdirAll("public", 0o755); err != nil { t.Fatal(err) } swapChooserIn(t, "\n") artifact, kind, _, err := deployArtifactWizard() if err != nil || artifact != "./public" || kind != "static" { t.Fatalf("suggested dir = %q %q %v", artifact, kind, err) } // Two conventional dirs: no suggestion, the empty answer is a miss and // a real answer on the retry lands. if err := os.MkdirAll("dist", 0o755); err != nil { t.Fatal(err) } swapChooserIn(t, "\npublic\n") if artifact, kind, _, err = deployArtifactWizard(); err != nil || artifact != "public" || kind != "static" { t.Fatalf("ambiguous dirs = %q %q %v", artifact, kind, err) } // A regular file is a binary, target pre-filled. if err := os.WriteFile("server", []byte("bin"), 0o755); err != nil { t.Fatal(err) } swapChooserIn(t, "server\n\n") artifact, kind, target, err := deployArtifactWizard() if err != nil || artifact != "server" || kind != "binary" || target != "linux-arm64" { t.Fatalf("binary = %q %q %q %v", artifact, kind, target, err) } // A missing path re-prompts once, then the wizard gives up with the // build-first message. swapChooserIn(t, "nope\nalso-nope\n") if _, _, _, err = deployArtifactWizard(); err == nil || !strings.Contains(err.Error(), "nothing to ship") { t.Fatalf("two misses = %v", err) } } // swapChooserIn points the shared prompt reader at a script for one test. func swapChooserIn(t *testing.T, script string) { t.Helper() old := chooserIn chooserIn = strings.NewReader(script) t.Cleanup(func() { chooserIn = old }) } func TestOfferCreateAppUsesDirectoryName(t *testing.T) { useTempCredentials(t) captureStdout(t) forceTerminal(t, true) var gotName string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var in struct { Name string `json:"name"` } json.NewDecoder(r.Body).Decode(&in) gotName = in.Name json.NewEncoder(w).Encode(map[string]any{"app": in.Name, "account": "bdk", "url": "https://x/a/bdk/" + in.Name}) })) t.Cleanup(ts.Close) // cwd's base name is the suggestion — make it a valid app name. dir := filepath.Join(t.TempDir(), "taskmonger") if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } t.Chdir(dir) swapChooserIn(t, "y\n") s := newTestSession(ts.URL) app := offerCreateApp(context.Background(), s, apiAcct{Seg: "bdk", Exact: true}, &noAppsError{Account: "tom@keymail.dev"}, "") if app != "taskmonger" || gotName != "taskmonger" { t.Fatalf("created %q (server saw %q), want the directory's name", app, gotName) } } func TestProjectArtifactContainment(t *testing.T) { root := t.TempDir() dsrc := filepath.Join(root, ".carlos", "config") if err := os.MkdirAll(filepath.Join(root, "public"), 0o755); err != nil { t.Fatal(err) } // Inside the tree: resolved against the config's root, so a saved // "./public" works from any subdirectory. got, err := projectArtifact(dsrc, "./public") if err != nil || got != filepath.Join(root, "public") { t.Fatalf("in-tree = %q, %v", got, err) } // The escape shapes a hostile committed config would use: every one a // refusal, never a pack (a config artifact of ../../.carlos would tar // the operator's credentials and PUBLISH them). for _, bad := range []string{"/etc", "../outside", "../../.carlos", "public/../../x"} { if _, err := projectArtifact(dsrc, bad); err == nil { t.Errorf("projectArtifact(%q) accepted an escape", bad) } } // A symlink inside the tree pointing outside it is the same escape. outside := t.TempDir() if err := os.Symlink(outside, filepath.Join(root, "site")); err != nil { t.Fatal(err) } if _, err := projectArtifact(dsrc, "site"); err == nil { t.Error("a symlink escaping the tree was accepted") } // A stale saved path is an error naming the file to edit. if _, err := projectArtifact(dsrc, "gone"); err == nil || !strings.Contains(err.Error(), ".carlos/config") { t.Fatalf("missing artifact = %v, want an error naming the config", err) } } func TestDeployTargetsAmbiguityIsTyped(t *testing.T) { // No instances at all: a hard refusal that merely MENTIONS -channel — // the chooser must never answer it (it once converted this refusal // into a ship+promote). _, _, _, err := deployTargets(nil, "binary", "", "", func() (apiChannelsResult, error) { return apiChannelsResult{}, nil }) if err == nil || errors.Is(err, errAmbiguousChannel) { t.Fatalf("no-instances = %v, want a NON-ambiguity refusal", err) } // Instances split across channels: genuine ambiguity, typed. recs := []apiInstanceRecord{{Host: "a.x", Channel: "canary/a"}, {Host: "b.x", Channel: "canary/b"}} _, _, _, err = deployTargets(recs, "binary", "", "", func() (apiChannelsResult, error) { return apiChannelsResult{}, nil }) if !errors.Is(err, errAmbiguousChannel) { t.Fatalf("split channels = %v, want errAmbiguousChannel", err) } // Static app, no instances, several channels: ambiguity, typed. chans := apiChannelsResult{Channels: []apiChannel{{Channel: "canary/site"}, {Channel: "stable"}}} _, _, _, err = deployTargets(nil, "static", "", "h.example", func() (apiChannelsResult, error) { return chans, nil }) if !errors.Is(err, errAmbiguousChannel) { t.Fatalf("static several channels = %v, want errAmbiguousChannel", err) } } func TestChooseDeployChannel(t *testing.T) { useTempCredentials(t) captureStdout(t) serve := func(body string) *apiSession { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(body)) })) t.Cleanup(ts.Close) return newTestSession(ts.URL) } // Fewer than two CLEAN channels: no chooser — back to the refusal. s := serve(`{"channels":[{"channel":"canary/a"},{"channel":"bad","error":"unsigned"}]}`) if _, ok := chooseDeployChannel(context.Background(), s, apiAcct{Seg: "bdk"}, "shop"); ok { t.Fatal("one clean channel must not offer a choice") } // Two clean ones: numbered pick. s = serve(`{"channels":[{"channel":"canary/a"},{"channel":"canary/b"}]}`) swapChooserIn(t, "2\n") picked, ok := chooseDeployChannel(context.Background(), s, apiAcct{Seg: "bdk"}, "shop") if !ok || picked != "canary/b" { t.Fatalf("pick = %q %v", picked, ok) } } func TestResolvedAppExplicitFlagBypassesTheFetch(t *testing.T) { useTempCredentials(t) captureStdout(t) s, hits := accountsConsole(t, http.StatusOK, `{"accounts":[]}`) if got := resolvedApp(s, apiAcct{Seg: "bdk", Exact: true}, "shop", "usage"); got != "shop" { t.Fatalf("explicit --app = %q", got) } if *hits != 0 { t.Fatalf("explicit --app must never consult the endpoint; hits = %d", *hits) } } // The second-run promise, end to end as a real subprocess: a checkout with // saved deploy defaults runs `carlos deploy` with NO arguments and NO // prompts (non-TTY here, so any prompt would hang or misroute) — account, // app, kind and artifact all come from .carlos/config, proven by the muted // "defaults from" line and by reaching deploy's own no-instances refusal // (the first thing after resolution for this fixture) rather than any // usage or login error. func TestZeroArgDeployRunsFromSavedDefaults(t *testing.T) { bin := buildCarlos(t) mux := http.NewServeMux() mux.HandleFunc("GET /api/cli/apps", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev","apps":["taskmonger"]}]}`)) }) mux.HandleFunc("GET /api/cli/apps/", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"instances":[]}`)) // instance list: none }) ts := httptest.NewServer(mux) t.Cleanup(ts.Close) project := t.TempDir() if err := os.MkdirAll(filepath.Join(project, ".carlos"), 0o755); err != nil { t.Fatal(err) } cfg := `{"console":"` + ts.URL + `","account":"tom@keymail.dev","app":"taskmonger","kind":"binary","artifact":"./bin/server","target":"linux-arm64"}` if err := os.WriteFile(filepath.Join(project, ".carlos", "config"), []byte(cfg), 0o644); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Join(project, "bin"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(project, "bin", "server"), []byte("bin"), 0o755); err != nil { t.Fatal(err) } credPath := filepath.Join(t.TempDir(), "credentials") creds := `{"version":1,"credentials":[{"console":"` + ts.URL + `","address":"tom@keymail.dev","token":"tok"}]}` if err := os.WriteFile(credPath, []byte(creds), 0o600); err != nil { t.Fatal(err) } cmd := exec.Command(bin, "deploy", "-version", "x1") // scratch dir: no git for gitShortHead cmd.Env = carlosTestEnv(t, "CARLOS_CREDENTIALS="+credPath) cmd.Dir = project out, err := cmd.CombinedOutput() if err == nil { t.Fatalf("no-instances fixture must refuse, got success:\n%s", out) } if !strings.Contains(string(out), "the defaults from") || !strings.Contains(string(out), "no instances — create one") { t.Fatalf("zero-arg deploy from saved defaults = %q\nwant the defaults line and the no-instances refusal", out) } for _, never := range []string{"[y/N]", "usage:", "not logged in", "artifact to deploy"} { if strings.Contains(string(out), never) { t.Fatalf("zero-question run printed %q:\n%s", never, out) } } } // And the same shape with NOTHING saved and no terminal: the one error that // names both remedies, before any network prompt could hang a script. func TestZeroArgDeployNonTTYWithoutDefaults(t *testing.T) { bin := buildCarlos(t) cmd := exec.Command(bin, "deploy", "-version", "x1") // scratch dir: no git for gitShortHead cmd.Env = carlosTestEnv(t) cmd.Dir = t.TempDir() out, err := cmd.CombinedOutput() if err == nil { t.Fatalf("bare deploy with nothing configured succeeded:\n%s", out) } if !strings.Contains(string(out), "no deployment target configured") { t.Fatalf("bare deploy = %q, want the no-target refusal (the session is the first blocker)", out) } } func TestAccountName(t *testing.T) { s := &apiSession{accts: []apiAccountEntry{{Name: "tom@keymail.dev", Sqid: "bdk"}}} if got := s.accountName(apiAcct{Seg: "bdk", Exact: true}); got != "tom@keymail.dev" { t.Fatalf("by sqid = %q", got) } if got := s.accountName(apiAcct{Seg: "acme"}); got != "acme" { t.Fatalf("unknown segment must pass through, got %q", got) } }