| index fa92eb506..28e2e7717 100644 |
| --- a/apimode_test.go |
| +++ b/apimode_test.go |
| @@ -18,6 +18,7 @@ import ( |
| "os" |
| "path/filepath" |
| "strings" |
| + "sync" |
| "sync/atomic" |
| "testing" |
| "time" |
| @@ -52,6 +53,7 @@ func clearDeploymentEnv(t *testing.T) { |
| t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "") |
| t.Setenv("CARLOS_DEPLOYMENT_DIR", "") |
| t.Setenv("CARLOS_CONSOLE", "") |
| + t.Setenv("CARLOS_SERVICE_TOKEN", "") |
| } |
| |
| func TestResolveModeDirectWins(t *testing.T) { |
| @@ -3877,3 +3879,195 @@ func TestSealSecretAPIBindsWhenTheConsoleNamesAnAccount(t *testing.T) { |
| t.Fatalf("fallback envelope is not the v1 shape an older door accepts (first byte %#x)", raw[0]) |
| } |
| } |
| + |
| +func TestResolveModeServiceToken(t *testing.T) { |
| + useIsolatedConfig(t) |
| + clearDeploymentEnv(t) |
| + var gotAuth string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotAuth = r.Header.Get("Authorization") |
| + w.Header().Set("Content-Type", "application/json") |
| + w.Write([]byte(`{"accounts":[]}`)) |
| + })) |
| + defer ts.Close() |
| + // A person's credential for another console is on disk too. The token |
| + // wins and the file is not consulted. |
| + f, _ := loadCredentials() |
| + f.put(credential{Console: "https://a.example", Address: "a@keymail.dev", Token: "person", ExpiresAt: time.Now().Add(time.Hour)}) |
| + if err := f.save(); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Setenv("CARLOS_SERVICE_TOKEN", "svc-secret") |
| + t.Setenv("CARLOS_CONSOLE", ts.URL) |
| + sess, err := resolveMode("") |
| + if err != nil || sess == nil { |
| + t.Fatalf("resolveMode = %v, %v", sess, err) |
| + } |
| + if sess.address != serviceSessionLabel { |
| + t.Fatalf("address = %q", sess.address) |
| + } |
| + if _, _, err := sess.getJSON(context.Background(), "/api/cli/apps"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if gotAuth != "Bearer svc-secret" { |
| + t.Fatalf("Authorization = %q, want the service token", gotAuth) |
| + } |
| +} |
| + |
| +func TestResolveModeServiceTokenNeedsAConsole(t *testing.T) { |
| + useIsolatedConfig(t) |
| + clearDeploymentEnv(t) |
| + t.Setenv("CARLOS_SERVICE_TOKEN", "svc-secret") |
| + if _, err := resolveMode(""); err == nil || !strings.Contains(err.Error(), "CARLOS_CONSOLE") { |
| + t.Fatalf("err = %v, want D27", err) |
| + } |
| +} |
| + |
| +func TestResolveModeServiceTokenRefusedWithStoreEnv(t *testing.T) { |
| + for _, env := range []string{"CARLOS_DEPLOYMENT_BUCKET", "CARLOS_DEPLOYMENT_DIR"} { |
| + t.Run(env, func(t *testing.T) { |
| + useIsolatedConfig(t) |
| + clearDeploymentEnv(t) |
| + t.Setenv("CARLOS_SERVICE_TOKEN", "svc-secret") |
| + t.Setenv("CARLOS_CONSOLE", "https://c.example") |
| + t.Setenv(env, t.TempDir()) |
| + sess, err := resolveMode("") |
| + if err == nil || sess != nil || !strings.Contains(err.Error(), "CARLOS_SERVICE_TOKEN") { |
| + t.Fatalf("= %v, %v; want D28", sess, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// A bare store variable still selects direct mode silently when no token is |
| +// set: the token arm changes nothing for anyone without one. |
| +func TestResolveModeStoreEnvWithoutTokenStillDirect(t *testing.T) { |
| + useIsolatedConfig(t) |
| + clearDeploymentEnv(t) |
| + t.Setenv("CARLOS_DEPLOYMENT_DIR", t.TempDir()) |
| + if sess, err := resolveMode(""); sess != nil || err != nil { |
| + t.Fatalf("= %v, %v; want (nil, nil)", sess, err) |
| + } |
| +} |
| + |
| +// carlosDeployDoors is a COPY of spec §6's list of doors a deploy credential |
| +// may reach (R6 included). The root package cannot import the console's test |
| +// variable, so the source of truth is deployDoors in |
| +// internal/console/deploydoors_test.go: change one, change the other. |
| +var carlosDeployDoors = []string{ |
| + "GET /api/cli/apps", |
| + "GET /api/cli/apps/{account}/{app}/channels", |
| + "POST /api/cli/apps/{account}/{app}/releases", |
| + "GET /api/cli/apps/{account}/{app}/releases", |
| + "POST /api/cli/apps/{account}/{app}/promote", |
| + "POST /api/cli/apps/{account}/{app}/rollback", |
| + "GET /api/cli/apps/{account}/{app}/tags", |
| + "GET /api/cli/apps/{account}/{app}/tags/{version}", |
| + "GET /api/cli/apps/{account}/{app}/version-target", |
| + "GET /api/cli/apps/{account}/{app}/pipeline", |
| + "GET /api/cli/apps/{account}/{app}/pending", |
| + "GET /api/cli/apps/{account}/{app}/instances", |
| + "GET /api/cli/apps/{account}/{app}/regions", |
| +} |
| + |
| +// doorPattern maps a recorded "METHOD /path" back to the route pattern it |
| +// was served by: the account and app segments become {account} and {app}, |
| +// and the segment after "tags" becomes {version}. |
| +func doorPattern(method, path string) string { |
| + if i := strings.IndexByte(path, '?'); i >= 0 { |
| + path = path[:i] |
| + } |
| + seg := strings.Split(strings.Trim(path, "/"), "/") |
| + // api, cli, apps, account, app, tail... |
| + if len(seg) >= 5 { |
| + seg[3], seg[4] = "{account}", "{app}" |
| + if len(seg) == 7 && seg[5] == "tags" { |
| + seg[6] = "{version}" |
| + } |
| + } |
| + return method + " /" + strings.Join(seg, "/") |
| +} |
| + |
| +func TestCarlosDeployRoutinePathUsesOnlyDeployDoors(t *testing.T) { |
| + var mu sync.Mutex |
| + var seen []string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + mu.Lock() |
| + seen = append(seen, r.Method+" "+r.URL.Path) |
| + mu.Unlock() |
| + w.Header().Set("Content-Type", "application/json") |
| + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/releases") { |
| + w.WriteHeader(http.StatusCreated) |
| + w.Write([]byte(`{"result":"shipped","version":"1.2.3","artifacts":{}}`)) |
| + return |
| + } |
| + w.Write([]byte(`{"result":"promoted"}`)) |
| + })) |
| + defer ts.Close() |
| + record := func() []string { |
| + mu.Lock() |
| + defer mu.Unlock() |
| + out := seen |
| + seen = nil |
| + return out |
| + } |
| + |
| + file := filepath.Join(t.TempDir(), "a.bin") |
| + if err := os.WriteFile(file, []byte("aaa"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + ctx := context.Background() |
| + s := newTestSession(ts.URL) |
| + a := apiAcct{Seg: "bab", Exact: true} |
| + |
| + steps := []struct { |
| + name string |
| + run func() error |
| + }{ |
| + {"ship", func() error { |
| + _, err := s.ship(ctx, a, "app", "1.2.3", "binary", "", "", map[string]string{"alpha-amd64": file}, nil) |
| + return err |
| + }}, |
| + {"promote", func() error { _, err := s.promote(ctx, a, "app", "1.2.3", "canary", false); return err }}, |
| + {"rollback", func() error { return s.rollback(ctx, a, "app", "canary") }}, |
| + {"channels", func() error { _, err := s.channels(ctx, a, "app"); return err }}, |
| + {"instanceList", func() error { _, _, _, err := s.instanceList(ctx, a, "app", "", ""); return err }}, |
| + {"appRegions", func() error { _, err := s.appRegions(ctx, a, "app"); return err }}, |
| + } |
| + allowed := map[string]bool{} |
| + for _, d := range carlosDeployDoors { |
| + allowed[d] = true |
| + } |
| + for _, st := range steps { |
| + if err := st.run(); err != nil { |
| + t.Fatalf("%s: %v", st.name, err) |
| + } |
| + reqs := record() |
| + if len(reqs) == 0 { |
| + t.Fatalf("%s sent no request", st.name) |
| + } |
| + for _, r := range reqs { |
| + method, path, _ := strings.Cut(r, " ") |
| + if p := doorPattern(method, path); !allowed[p] { |
| + t.Errorf("%s reached %q (%s), which a deploy credential is not given", st.name, r, p) |
| + } |
| + } |
| + } |
| + |
| + // The watch's diagnosis reads the logs. That stays a best-effort read a |
| + // deploy credential is refused, so it must not be on the list. |
| + if _, err := s.logsGet(ctx, a, "app", nil); err != nil { |
| + t.Fatalf("logsGet: %v", err) |
| + } |
| + reqs := record() |
| + if len(reqs) != 1 { |
| + t.Fatalf("logsGet sent %v", reqs) |
| + } |
| + method, path, _ := strings.Cut(reqs[0], " ") |
| + if !strings.HasSuffix(path, "/logs") { |
| + t.Fatalf("logsGet path = %q, want .../logs", path) |
| + } |
| + if p := doorPattern(method, path); allowed[p] { |
| + t.Errorf("%q is on the deploy door list; the logs read must stay refused to a deploy credential", p) |
| + } |
| +} |