| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "os/exec" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | "github.com/carlosframework/platform/internal/release" |
| 11 | ) |
| 12 | |
| 13 | // carlosTestEnv is os.Environ() with the operator's real login and console |
| 14 | // state masked: CARLOS_CREDENTIALS points into the test's own tempdir where |
| 15 | // no credential exists, HOME is a scratch dir (the binary reads |
| 16 | // ~/.carlos/config for the default console), and CARLOS_CONSOLE is cleared. |
| 17 | // Every subprocess that runs the carlos binary MUST build its env through |
| 18 | // this AND set cmd.Dir to a scratch dir — the binary walks cwd ancestors |
| 19 | // for .carlos/config, so a subprocess running from the real checkout reads |
| 20 | // the developer's real config the moment one exists. A test that inherits |
| 21 | // the real ~/.carlos is one `go test ./...` on a logged-in laptop away from |
| 22 | // writing to a production console — which is exactly how 40+ test accounts |
| 23 | // got minted on the flagship (2026-08-04). |
| 24 | // |
| 25 | // Duplicated from e2e_test.go rather than shared: that file is package |
| 26 | // main_test (an external test package with no access to this package's |
| 27 | // unexported symbols), this one is package main, and the two cannot share |
| 28 | // an unexported helper across the package boundary. |
| 29 | func carlosTestEnv(t *testing.T, extra ...string) []string { |
| 30 | t.Helper() |
| 31 | scratch := t.TempDir() |
| 32 | // An inherited CARLOS_SERVICE_TOKEN (a CI shell that exports one) would |
| 33 | // make any child that also sets CARLOS_DEPLOYMENT_DIR fail resolveMode's |
| 34 | // token-plus-store refusal, so it is dropped here. It is filtered rather |
| 35 | // than blanked: a test that passes its own token in extra still wins. |
| 36 | var env []string |
| 37 | for _, kv := range os.Environ() { |
| 38 | if !strings.HasPrefix(kv, "CARLOS_SERVICE_TOKEN=") { |
| 39 | env = append(env, kv) |
| 40 | } |
| 41 | } |
| 42 | env = append(env, |
| 43 | "CARLOS_CREDENTIALS="+filepath.Join(scratch, "credentials"), |
| 44 | "HOME="+scratch, |
| 45 | "CARLOS_CONSOLE=", |
| 46 | "CARLOS_DESTINATION=", |
| 47 | ) |
| 48 | return append(env, extra...) |
| 49 | } |
| 50 | |
| 51 | // The scoped signing key as an operator meets it: two modes of one verb, and |
| 52 | // a promote that signs with whatever CARLOS_RELEASE_KEY holds. Driven through |
| 53 | // the real binary, because the whole feature is about which secret is sitting |
| 54 | // in which variable on which machine — a thing the functions underneath |
| 55 | // cannot be wrong about on their own. |
| 56 | func TestScopedKeysCLI(t *testing.T) { |
| 57 | bin := buildCarlos(t) |
| 58 | bucket := t.TempDir() |
| 59 | scratch := t.TempDir() |
| 60 | |
| 61 | run := func(env []string, args ...string) (string, error) { |
| 62 | t.Helper() |
| 63 | cmd := exec.Command(bin, args...) |
| 64 | cmd.Env = carlosTestEnv(t, |
| 65 | append([]string{ |
| 66 | "CARLOS_DEPLOYMENT_DIR=" + bucket, |
| 67 | "CARLOS_DATA_DIR=" + t.TempDir(), |
| 68 | }, env...)...) |
| 69 | // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. |
| 70 | cmd.Dir = scratch |
| 71 | out, err := cmd.CombinedOutput() |
| 72 | return string(out), err |
| 73 | } |
| 74 | |
| 75 | // -- root keygen, unchanged ------------------------------------------ |
| 76 | // |
| 77 | // Mutation-mindedness: any change to the no-flag output breaks every |
| 78 | // deployment's bootstrap instructions, so it is asserted key by key. |
| 79 | out, err := run(nil, "release-keygen") |
| 80 | if err != nil { |
| 81 | t.Fatalf("release-keygen: %v\n%s", err, out) |
| 82 | } |
| 83 | rootKey := envValue(t, out, "CARLOS_RELEASE_KEY") |
| 84 | rootPub := envValue(t, out, "CARLOS_RELEASE_PUBKEY") |
| 85 | if release.IsScopedSeed(rootKey) { |
| 86 | t.Fatal("the root key came out tagged as scoped") |
| 87 | } |
| 88 | rootEnv := []string{"CARLOS_RELEASE_KEY=" + rootKey, "CARLOS_RELEASE_PUBKEY=" + rootPub} |
| 89 | |
| 90 | // -name/-expires without -scope are refused rather than silently |
| 91 | // ignored: an operator who typed them meant to mint a scoped key, and |
| 92 | // walking away with a ROOT key they think is scoped is the worst |
| 93 | // possible outcome of this verb. |
| 94 | if out, err := run(rootEnv, "release-keygen", "-name", "edge-builder"); err == nil { |
| 95 | t.Fatalf("release-keygen --name without --scope minted a root key:\n%s", out) |
| 96 | } |
| 97 | |
| 98 | // -- scoped keygen --------------------------------------------------- |
| 99 | out, err = run(rootEnv, "release-keygen", "-scope", "canary,edge", "-name", "edge-builder") |
| 100 | if err != nil { |
| 101 | t.Fatalf("release-keygen --scope: %v\n%s", err, out) |
| 102 | } |
| 103 | scopedKey := envValue(t, out, "CARLOS_RELEASE_KEY") |
| 104 | grant := envValue(t, out, "CARLOS_RELEASE_GRANT") |
| 105 | if !release.IsScopedSeed(scopedKey) { |
| 106 | t.Fatalf("scoped keygen printed an untagged key %q", scopedKey) |
| 107 | } |
| 108 | // The fleet's anchor is not reprinted as an assignment. A second |
| 109 | // copy-pasteable CARLOS_RELEASE_PUBKEY= line is an invitation to install |
| 110 | // it on a box, which would give the deployment two trust anchors and |
| 111 | // defeat the entire scheme. (The prose guidance names the variable to say |
| 112 | // it does NOT change; that is the opposite of a footgun.) |
| 113 | for _, line := range strings.Split(out, "\n") { |
| 114 | if strings.HasPrefix(strings.TrimSpace(line), "CARLOS_RELEASE_PUBKEY=") { |
| 115 | t.Fatalf("scoped keygen printed a public key assignment:\n%s", out) |
| 116 | } |
| 117 | } |
| 118 | if !strings.Contains(out, "edge-builder") { |
| 119 | t.Fatalf("scoped keygen does not name the key it minted:\n%s", out) |
| 120 | } |
| 121 | |
| 122 | // stable is refused at mint, by the verb, naming stable. |
| 123 | if out, err := run(rootEnv, "release-keygen", "-scope", "edge,stable", "-name", "bad"); err == nil { |
| 124 | t.Fatalf("release-keygen --scope edge,stable succeeded:\n%s", out) |
| 125 | } else if !strings.Contains(out, "stable") { |
| 126 | t.Fatalf("refusal does not name stable:\n%s", out) |
| 127 | } |
| 128 | |
| 129 | // Minting needs the ROOT key: a scoped key cannot widen itself. |
| 130 | scopedOnly := []string{"CARLOS_RELEASE_KEY=" + scopedKey, "CARLOS_RELEASE_PUBKEY=" + rootPub} |
| 131 | if out, err := run(scopedOnly, "release-keygen", "-scope", "beta", "-name", "wider"); err == nil { |
| 132 | t.Fatalf("a scoped key minted a grant:\n%s", out) |
| 133 | } |
| 134 | |
| 135 | // -- promoting with the scoped key ----------------------------------- |
| 136 | art := filepath.Join(t.TempDir(), "kass") |
| 137 | if err := os.WriteFile(art, []byte("build one"), 0o755); err != nil { |
| 138 | t.Fatal(err) |
| 139 | } |
| 140 | if out, err := run(rootEnv, "ship", "-app", "kass", "-version", "9f3c1ad", art); err != nil { |
| 141 | t.Fatalf("ship: %v\n%s", err, out) |
| 142 | } |
| 143 | |
| 144 | scopedEnv := append(append([]string{}, scopedOnly...), "CARLOS_RELEASE_GRANT="+grant) |
| 145 | if out, err := run(scopedEnv, "promote", "-app", "kass", "9f3c1ad", "edge"); err != nil { |
| 146 | t.Fatalf("scoped promote to edge: %v\n%s", err, out) |
| 147 | } |
| 148 | // It verifies for a reader holding only the ROOT public key — which is |
| 149 | // every box in the fleet. |
| 150 | out, err = run(rootEnv, "channels", "-app", "kass") |
| 151 | if err != nil { |
| 152 | t.Fatalf("channels: %v\n%s", err, out) |
| 153 | } |
| 154 | if !strings.Contains(out, "edge") || !strings.Contains(out, "9f3c1ad") { |
| 155 | t.Fatalf("channels does not show the scope-signed pointer:\n%s", out) |
| 156 | } |
| 157 | |
| 158 | // -- what the scoped key may not do ---------------------------------- |
| 159 | // |
| 160 | // stable is refused locally and nothing is written. The bucket check is |
| 161 | // the load-bearing half: an error message is easy, a channel pointer that |
| 162 | // never appeared is the property. |
| 163 | out, err = run(scopedEnv, "promote", "-app", "kass", "9f3c1ad", "stable") |
| 164 | if err == nil { |
| 165 | t.Fatalf("the edge key promoted to stable:\n%s", out) |
| 166 | } |
| 167 | for _, want := range []string{"edge-builder", "stable"} { |
| 168 | if !strings.Contains(out, want) { |
| 169 | t.Errorf("refusal does not name %q:\n%s", want, out) |
| 170 | } |
| 171 | } |
| 172 | if _, err := os.Stat(filepath.Join(bucket, "apps", release.OpsAccount, "kass", "channels", "stable.json")); !os.IsNotExist(err) { |
| 173 | t.Fatalf("a stable pointer exists after a refused promote (stat err = %v)", err) |
| 174 | } |
| 175 | |
| 176 | // -hotfix bypasses the ladder, never the grant. |
| 177 | if out, err := run(scopedEnv, "promote", "-app", "kass", "-hotfix", "9f3c1ad", "stable"); err == nil { |
| 178 | t.Fatalf("--hotfix let the edge key reach stable:\n%s", out) |
| 179 | } |
| 180 | |
| 181 | // A scoped key with no grant beside it fails HERE, naming the grant, |
| 182 | // rather than signing something the fleet will silently refuse later. |
| 183 | out, err = run(scopedOnly, "promote", "-app", "kass", "9f3c1ad", "edge") |
| 184 | if err == nil { |
| 185 | t.Fatalf("a scoped key promoted with no grant:\n%s", out) |
| 186 | } |
| 187 | if !strings.Contains(out, "CARLOS_RELEASE_GRANT") { |
| 188 | t.Fatalf("refusal does not name the missing grant:\n%s", out) |
| 189 | } |
| 190 | |
| 191 | // -- and the root key is still unrestricted -------------------------- |
| 192 | if out, err := run(rootEnv, "promote", "-app", "kass", "9f3c1ad", "beta"); err != nil { |
| 193 | t.Fatalf("root promote to beta: %v\n%s", err, out) |
| 194 | } |
| 195 | if out, err := run(rootEnv, "promote", "-app", "kass", "9f3c1ad", "stable"); err != nil { |
| 196 | t.Fatalf("root promote to stable: %v\n%s", err, out) |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | // envValue pulls VAR=value out of a verb's stdout. |
| 201 | func envValue(t *testing.T, out, key string) string { |
| 202 | t.Helper() |
| 203 | for _, line := range strings.Split(out, "\n") { |
| 204 | if v, ok := strings.CutPrefix(strings.TrimSpace(line), key+"="); ok { |
| 205 | return v |
| 206 | } |
| 207 | } |
| 208 | t.Fatalf("no %s in output:\n%s", key, out) |
| 209 | return "" |
| 210 | } |
| 211 | |