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 "bytes"
5 "encoding/json"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11)
12
13// writeConfig plants a .carlos/config under dir, the way a checked-in
14// project file or `carlos auth default` would.
15func writeConfig(t *testing.T, dir, body string) string {
16 t.Helper()
17 path := filepath.Join(dir, ".carlos", "config")
18 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
19 t.Fatal(err)
20 }
21 if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
22 t.Fatal(err)
23 }
24 return path
25}
26
27// useIsolatedConfig isolates an in-process test the way carlosTestEnv
28// isolates a subprocess (e2e_test.go): CARLOS_CONFIG is pointed at a path
29// inside a fresh t.TempDir() where no config exists, which makes walkConfig
30// and configDefault skip discovery entirely — no cwd walk, no global
31// fallback — so a developer's real ~/.carlos/config can never answer for a
32// test. HOME is also set to a separate scratch dir (some paths, like
33// credentialsPath's fallback, still derive from it), and CARLOS_CONSOLE and
34// CARLOS_CREDENTIALS are cleared so neither leaks in from the environment
35// running the test binary.
36//
37// This is deliberately an env override rather than a chdir: t.Chdir forbids
38// t.Parallel(), and CARLOS_CONFIG makes the cwd walk irrelevant for any test
39// that isn't exercising discovery itself. TestConfigDefaultWalk is the one
40// test that genuinely needs the walk, so it builds its own tree with
41// t.Chdir instead of calling this helper.
42//
43// Duplicated wherever package main_test needs the same isolation: this file
44// is package main, e2e_test.go is an external test package, and the two
45// cannot share an unexported helper across the package boundary.
46func useIsolatedConfig(t *testing.T) {
47 t.Helper()
48 t.Setenv("HOME", t.TempDir())
49 t.Setenv("CARLOS_CONFIG", filepath.Join(t.TempDir(), "config"))
50 t.Setenv("CARLOS_CONSOLE", "")
51 t.Setenv("CARLOS_CREDENTIALS", "")
52 t.Setenv("CARLOS_DESTINATION", "")
53}
54
55func TestLoadConfigFile(t *testing.T) {
56 useTempCredentials(t)
57 dir := t.TempDir()
58
59 // Missing file: no default at this level, not an error.
60 if c, err := loadConfigFile(filepath.Join(dir, ".carlos", "config")); err != nil || c != "" {
61 t.Fatalf("missing config = %q, %v; want empty, nil", c, err)
62 }
63
64 // A well-formed file: the console comes back normalized.
65 p := writeConfig(t, dir, `{"console": "https://tito.example/"}`+"\n")
66 if c, err := loadConfigFile(p); err != nil || c != "https://tito.example" {
67 t.Fatalf("loadConfigFile = %q, %v; want the normalized console", c, err)
68 }
69
70 // Unknown fields are ignored (additive evolution), and a file WITHOUT
71 // a console field is "no default at this level", not an error.
72 os.WriteFile(p, []byte(`{"future": true}`), 0o644)
73 if c, err := loadConfigFile(p); err != nil || c != "" {
74 t.Fatalf("config without console = %q, %v; want empty, nil", c, err)
75 }
76
77 // Malformed JSON is an error naming the file — silently skipping a
78 // typo'd config would ship to the wrong console.
79 os.WriteFile(p, []byte(`{`), 0o644)
80 if _, err := loadConfigFile(p); err == nil || !strings.Contains(err.Error(), p) {
81 t.Fatalf("malformed config = %v, want an error naming %s", err, p)
82 }
83
84 // A console normalizeConsole rejects is an error too, same reasoning.
85 os.WriteFile(p, []byte(`{"console": "ftp://nope"}`), 0o644)
86 if _, err := loadConfigFile(p); err == nil || !strings.Contains(err.Error(), p) {
87 t.Fatalf("bad-URL config = %v, want an error naming %s", err, p)
88 }
89}
90
91// TestConfigDefaultWalk exercises discovery itself — the cwd walk AND the
92// global fallback — so it cannot use useIsolatedConfig's CARLOS_CONFIG
93// override, which disables discovery entirely. Instead it owns one temp
94// tree for the whole test: HOME lives at the tree's root, and the working
95// directory is a descendant of that same root, so both the cwd walk and
96// the global fallback resolve inside the tree this test controls rather
97// than a developer's real ~/.carlos/config.
98func TestConfigDefaultWalk(t *testing.T) {
99 home := t.TempDir()
100 t.Setenv("HOME", home)
101 t.Setenv("CARLOS_CONSOLE", "")
102 t.Setenv("CARLOS_CREDENTIALS", filepath.Join(home, "credentials"))
103 root := filepath.Join(home, "project")
104 if err := os.MkdirAll(root, 0o755); err != nil {
105 t.Fatal(err)
106 }
107 t.Chdir(root)
108
109 // Nothing anywhere: no default, no error.
110 if c, src, err := configDefault(); err != nil || c != "" || src != "" {
111 t.Fatalf("bare configDefault = %q %q %v, want empty", c, src, err)
112 }
113
114 // Only the global: the global wins, and the source names the file.
115 globalPath := writeConfig(t, home, `{"console": "https://global.example"}`)
116 c, src, err := configDefault()
117 if err != nil || c != "https://global.example" || src != globalPath {
118 t.Fatalf("global-only = %q %q %v", c, src, err)
119 }
120
121 // A project config anywhere up the cwd walk beats the global, and the
122 // NEAREST project config beats a farther one.
123 writeConfig(t, root, `{"console": "https://outer.example"}`)
124 nested := filepath.Join(root, "cmd", "deep")
125 if err := os.MkdirAll(nested, 0o755); err != nil {
126 t.Fatal(err)
127 }
128 t.Chdir(nested)
129 if c, _, err = configDefault(); err != nil || c != "https://outer.example" {
130 t.Fatalf("walk from nested = %q %v, want the ancestor's config", c, err)
131 }
132 nearest := writeConfig(t, nested, `{"console": "https://inner.example"}`)
133 if c, src, err = configDefault(); err != nil || c != "https://inner.example" || src != nearest {
134 t.Fatalf("nearest = %q %q %v", c, src, err)
135 }
136
137 // A level whose config has no console does not stop the walk.
138 os.WriteFile(nearest, []byte(`{"future": true}`), 0o644)
139 if c, _, err = configDefault(); err != nil || c != "https://outer.example" {
140 t.Fatalf("empty-console level = %q %v, want the walk to continue", c, err)
141 }
142
143 // A malformed config mid-walk is an error, not a skip.
144 os.WriteFile(nearest, []byte(`not json`), 0o644)
145 if _, _, err = configDefault(); err == nil {
146 t.Fatal("malformed project config must be an error, not a skip")
147 }
148}
149
150func TestSaveConfigFile(t *testing.T) {
151 useTempCredentials(t)
152 dir := t.TempDir()
153 path := filepath.Join(dir, ".carlos", "config")
154
155 // The URL is validated and normalized on WRITE, so a bad default is
156 // caught when set, not at first use.
157 if err := saveConfigFile(path, "ftp://nope", 0o755); err == nil {
158 t.Fatal("saving an invalid console must fail")
159 }
160 if err := saveConfigFile(path, "https://tito.example/", 0o755); err != nil {
161 t.Fatal(err)
162 }
163 if c, err := loadConfigFile(path); err != nil || c != "https://tito.example" {
164 t.Fatalf("round-trip = %q, %v", c, err)
165 }
166 // Not a secret: world-readable is fine and expected in a repo.
167 fi, err := os.Stat(path)
168 if err != nil {
169 t.Fatal(err)
170 }
171 if fi.Mode().Perm() != 0o644 {
172 t.Fatalf("config mode = %v, want 0644", fi.Mode().Perm())
173 }
174}
175
176func TestPickHonorsConfigDefault(t *testing.T) {
177 useTempCredentials(t)
178 home := os.Getenv("HOME")
179 cwd, err := os.Getwd()
180 if err != nil {
181 t.Fatal(err)
182 }
183 now := time.Now().UTC()
184 f, _ := loadCredentials()
185 f.put(credential{Console: "https://flagship.example", Address: "p@keymail.dev",
186 Token: "t-flag", ExpiresAt: now.Add(time.Hour)})
187 f.put(credential{Console: "https://tito.example", Address: "p@keymail.dev",
188 Token: "t-tito", ExpiresAt: now.Add(time.Hour)})
189
190 // Two credentials, no default: today's refusal — and it now also says
191 // how to stop having to choose.
192 if _, err := f.pick(""); err == nil || !strings.Contains(err.Error(), "carlos auth default") {
193 t.Fatalf("multi-credential refusal = %v, want the set-a-default hint", err)
194 }
195
196 // A global default resolves the ambiguity.
197 writeConfig(t, home, `{"console": "https://tito.example"}`)
198 c, err := f.pick("")
199 if err != nil || c.Token != "t-tito" {
200 t.Fatalf("pick with global default = %+v %v, want the tito credential", c, err)
201 }
202
203 // A project default overrides the global.
204 writeConfig(t, cwd, `{"console": "https://flagship.example"}`)
205 if c, err = f.pick(""); err != nil || c.Token != "t-flag" {
206 t.Fatalf("pick with project default = %+v %v, want the flagship credential", c, err)
207 }
208
209 // CARLOS_CONSOLE still beats any config.
210 t.Setenv("CARLOS_CONSOLE", "https://tito.example")
211 if c, err = f.pick(""); err != nil || c.Token != "t-tito" {
212 t.Fatalf("env over config = %+v %v", c, err)
213 }
214 t.Setenv("CARLOS_CONSOLE", "")
215
216 // An explicit flag still beats everything.
217 if c, err = f.pick("https://tito.example"); err != nil || c.Token != "t-tito" {
218 t.Fatalf("flag over config = %+v %v", c, err)
219 }
220}
221
222func TestConfigDefaultIsAuthoritative(t *testing.T) {
223 useTempCredentials(t)
224 cwd, err := os.Getwd()
225 if err != nil {
226 t.Fatal(err)
227 }
228 // The titogo case: the checkout is pinned to a stack this machine holds
229 // no credential for, and the only credential is for a DIFFERENT console.
230 // Falling back to it would quietly ship the project to the wrong
231 // deployment — the exact mistake the default exists to prevent.
232 f, _ := loadCredentials()
233 f.put(credential{Console: "https://flagship.example", Address: "p@keymail.dev",
234 Token: "t-flag", ExpiresAt: time.Now().Add(time.Hour)})
235 src := writeConfig(t, cwd, `{"console": "https://tito.example"}`)
236
237 _, err = f.pick("")
238 if err == nil {
239 t.Fatal("a set default with no credential must refuse, not fall back")
240 }
241 for _, want := range []string{"https://tito.example", src} {
242 if !strings.Contains(err.Error(), want) {
243 t.Fatalf("authoritative refusal %q does not name %q", err, want)
244 }
245 }
246
247 // A malformed config is an error even though a usable credential exists.
248 os.WriteFile(src, []byte(`{`), 0o644)
249 if _, err := f.pick(""); err == nil {
250 t.Fatal("malformed config must be an error, not a skip to the credential")
251 }
252}
253
254// The login chain: flag → CARLOS_CONSOLE → project config → global config →
255// the flagship. Login is the one verb with a built-in landing place; there
256// may be no credential yet, and the flagship is the product answer.
257func TestLoginConsoleChain(t *testing.T) {
258 useTempCredentials(t)
259 home := os.Getenv("HOME")
260 cwd, err := os.Getwd()
261 if err != nil {
262 t.Fatal(err)
263 }
264
265 if got, err := loginConsole(""); err != nil || got != defaultConsole {
266 t.Fatalf("bare loginConsole = %q %v, want the flagship", got, err)
267 }
268 writeConfig(t, home, `{"console": "https://global.example"}`)
269 if got, err := loginConsole(""); err != nil || got != "https://global.example" {
270 t.Fatalf("global config = %q %v", got, err)
271 }
272 writeConfig(t, cwd, `{"console": "https://project.example"}`)
273 if got, err := loginConsole(""); err != nil || got != "https://project.example" {
274 t.Fatalf("project config = %q %v", got, err)
275 }
276 t.Setenv("CARLOS_CONSOLE", "https://env.example")
277 if got, err := loginConsole(""); err != nil || got != "https://env.example" {
278 t.Fatalf("env = %q %v", got, err)
279 }
280 if got, err := loginConsole("https://flag.example"); err != nil || got != "https://flag.example" {
281 t.Fatalf("flag = %q %v", got, err)
282 }
283 // A malformed config fails the login rather than silently logging in
284 // to the flagship.
285 t.Setenv("CARLOS_CONSOLE", "")
286 os.WriteFile(filepath.Join(cwd, ".carlos", "config"), []byte(`{`), 0o644)
287 if _, err := loginConsole(""); err == nil {
288 t.Fatal("malformed config must fail the login, not skip to the flagship")
289 }
290}
291
292// End to end: with a configured default and no -console at all, login goes
293// where the config points.
294func TestAuthLoginUsesConfiguredDefault(t *testing.T) {
295 useTempCredentials(t)
296 fc := newFakeConsole(t)
297 writeConfig(t, os.Getenv("HOME"), `{"console": "`+fc.ts.URL+`"}`)
298 var out bytes.Buffer
299 if err := authLogin(&out, nil); err != nil {
300 t.Fatalf("authLogin via config default: %v\n%s", err, out.String())
301 }
302 f, _ := loadCredentials()
303 if c := f.find(fc.ts.URL); c == nil || c.Token != "cli-token" {
304 t.Fatalf("credential stored = %+v, want one for the configured console", c)
305 }
306}
307
308func TestAuthDefaultVerb(t *testing.T) {
309 useTempCredentials(t)
310 cwd, err := os.Getwd()
311 if err != nil {
312 t.Fatal(err)
313 }
314 var out bytes.Buffer
315
316 // Nothing configured: the report explains the built-in behavior rather
317 // than printing an empty string.
318 if err := authDefault(&out, nil); err != nil {
319 t.Fatal(err)
320 }
321 for _, want := range []string{"no default", defaultConsole, "carlos auth default"} {
322 if !strings.Contains(out.String(), want) {
323 t.Fatalf("bare report missing %q:\n%s", want, out.String())
324 }
325 }
326
327 // Set the global; the value is normalized on write and read back with
328 // its source.
329 out.Reset()
330 if err := authDefault(&out, []string{"https://global.example/"}); err != nil {
331 t.Fatal(err)
332 }
333 out.Reset()
334 if err := authDefault(&out, nil); err != nil {
335 t.Fatal(err)
336 }
337 if !strings.Contains(out.String(), "https://global.example") ||
338 !strings.Contains(out.String(), filepath.Join(".carlos", "config")) {
339 t.Fatalf("report after set:\n%s", out.String())
340 }
341
342 // -project writes ./.carlos/config, which then overrides the global.
343 out.Reset()
344 if err := authDefault(&out, []string{"-project", "https://project.example"}); err != nil {
345 t.Fatal(err)
346 }
347 if _, err := os.Stat(filepath.Join(cwd, ".carlos", "config")); err != nil {
348 t.Fatalf("--project did not write ./.carlos/config: %v", err)
349 }
350 out.Reset()
351 if err := authDefault(&out, nil); err != nil {
352 t.Fatal(err)
353 }
354 if !strings.Contains(out.String(), "https://project.example") {
355 t.Fatalf("project default not effective:\n%s", out.String())
356 }
357
358 // A bad URL is refused at set time, and -project with nothing to set
359 // is a usage error, not a silent no-op.
360 if err := authDefault(&out, []string{"ftp://nope"}); err == nil {
361 t.Fatal("setting an invalid console must fail")
362 }
363 if err := authDefault(&out, []string{"-project"}); err == nil {
364 t.Fatal("--project with no URL must be a usage error")
365 }
366 if err := authDefault(&out, []string{"a", "b"}); err == nil {
367 t.Fatal("two positional args must be a usage error")
368 }
369}
370
371func TestConfigAccountWalk(t *testing.T) {
372 useTempCredentials(t)
373 home := os.Getenv("HOME")
374
375 // Nothing anywhere: no account default, no error.
376 if a, src, cons, err := configAccount(); err != nil || a != "" || src != "" || cons != "" {
377 t.Fatalf("bare configAccount = %q %q %q %v, want empty", a, src, cons, err)
378 }
379
380 // Only the global, and the file names no console: a pure account
381 // default, fileConsole empty.
382 globalPath := writeConfig(t, home, `{"account": "acme"}`)
383 a, src, cons, err := configAccount()
384 if err != nil || a != "acme" || src != globalPath || cons != "" {
385 t.Fatalf("global-only = %q %q %q %v", a, src, cons, err)
386 }
387
388 // A nearer file with only a console does not stop the ACCOUNT walk.
389 root := t.TempDir()
390 writeConfig(t, root, `{"console": "https://outer.example"}`)
391 nested := filepath.Join(root, "cmd", "deep")
392 if err := os.MkdirAll(nested, 0o755); err != nil {
393 t.Fatal(err)
394 }
395 t.Chdir(nested)
396 if a, _, _, err = configAccount(); err != nil || a != "acme" {
397 t.Fatalf("console-only level = %q %v, want the walk to continue to acme", a, err)
398 }
399
400 // The nearest account-bearing file wins and reports ITS console —
401 // normalized, because the pairing rule compares it to a session console.
402 nearest := writeConfig(t, nested, `{"console": "https://inner.example/", "account": "tools"}`)
403 a, src, cons, err = configAccount()
404 if err != nil || a != "tools" || src != nearest || cons != "https://inner.example" {
405 t.Fatalf("nearest = %q %q %q %v", a, src, cons, err)
406 }
407
408 // Malformed config mid-walk is an error, not a skip.
409 os.WriteFile(nearest, []byte(`not json`), 0o644)
410 if _, _, _, err = configAccount(); err == nil {
411 t.Fatal("malformed project config must be an error, not a skip")
412 }
413}
414
415func TestConfigRewritesPreserveUnknownFields(t *testing.T) {
416 useTempCredentials(t)
417 dir := t.TempDir()
418 path := filepath.Join(dir, ".carlos", "config")
419
420 // A fresh saveConfigAccount writes both keys.
421 if err := saveConfigAccount(path, "https://tito.example/", "acme", 0o755); err != nil {
422 t.Fatal(err)
423 }
424 cfg, err := loadConfig(path)
425 if err != nil || cfg.Console != "https://tito.example" || cfg.Account != "acme" {
426 t.Fatalf("fresh save = %+v, %v", cfg, err)
427 }
428
429 // A file holding a key from the future keeps it across BOTH rewrites —
430 // the old drop-on-rewrite behavior made every new key lossy.
431 if err := os.WriteFile(path, []byte(`{"console":"https://tito.example","future_key":42}`), 0o644); err != nil {
432 t.Fatal(err)
433 }
434 if err := saveConfigAccount(path, "https://tito.example", "tools", 0o755); err != nil {
435 t.Fatal(err)
436 }
437 raw, err := os.ReadFile(path)
438 if err != nil {
439 t.Fatal(err)
440 }
441 var all map[string]any
442 if err := json.Unmarshal(raw, &all); err != nil {
443 t.Fatal(err)
444 }
445 if all["future_key"] != float64(42) || all["account"] != "tools" {
446 t.Fatalf("saveConfigAccount dropped fields: %v", all)
447 }
448 if err := saveConfigFile(path, "https://other.example/", 0o755); err != nil {
449 t.Fatal(err)
450 }
451 raw, _ = os.ReadFile(path)
452 all = map[string]any{}
453 if err := json.Unmarshal(raw, &all); err != nil {
454 t.Fatal(err)
455 }
456 if all["future_key"] != float64(42) || all["account"] != "tools" || all["console"] != "https://other.example" {
457 t.Fatalf("saveConfigFile dropped fields: %v", all)
458 }
459
460 // The console is still validated on write, account or not.
461 if err := saveConfigAccount(path, "ftp://nope", "x", 0o755); err == nil {
462 t.Fatal("saving an invalid console must fail")
463 }
464}
465