carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
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
17func 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
29func 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.
73func 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
80func 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
111func 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
150func 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
174func 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
200func 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.
219func 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.
271func 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
285func 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