deploy credential: final review fixes (#229)
F-1: carlosTestEnv (both copies) drops an inherited CARLOS_SERVICE_TOKEN so subprocess tests do not hit the token-plus-store refusal. F-2: handleAPIRollback gives a deploy credential the D22 refusal for stable instead of the legacy passkey text. F-3: test pinning that the promote door ignores fast_track/fastTrack from a deploy credential.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
6 files changed,
+129
−2
.superpowers/sdd/2026-10-05-deploy-credential/final-fix-report.md+32 −0e2e_test.go+11 −1internal/console/apicli.go+11 −0internal/console/deployscope_test.go+39 −0internal/console/deploysign_test.go+25 −0scopedkeys_e2e_test.go+11 −1
diff --git a/.superpowers/sdd/2026-10-05-deploy-credential/final-fix-report.md b/.superpowers/sdd/2026-10-05-deploy-credential/final-fix-report.md| new file mode 100644 |
| index 000000000..64cc061fc |
| --- /dev/null |
| +++ b/.superpowers/sdd/2026-10-05-deploy-credential/final-fix-report.md |
| @@ -0,0 +1,32 @@ |
| +# Final whole-branch review fixes (#229) |
| + |
| +## F-1 subprocess tests inherit CARLOS_SERVICE_TOKEN |
| +Both `carlosTestEnv` helpers (e2e_test.go, scopedkeys_e2e_test.go) now build the child env by filtering any |
| +`CARLOS_SERVICE_TOKEN=` entry out of `os.Environ()` before appending the test overrides. A token a test passes |
| +in `extra` still wins. No production code changed. |
| +Covering: every subprocess test using carlosTestEnv, notably TestScopedKeysCLI and TestRestartSelectorRefusals |
| +(sets CARLOS_DEPLOYMENT_DIR). |
| +Command: `CARLOS_SERVICE_TOKEN=dummy go test . -run 'TestScopedKeysCLI|TestRestartSelectorRefusals|TestE2E|TestAuth' -count=1` |
| +Output: `ok github.com/carlosframework/platform 30.068s` |
| +Note: the whole root package with the token exported still fails, in TestRunDomainsAttachPrintsAttached, an |
| +in-process run() test (not a subprocess helper) that exits with "CARLOS_SERVICE_TOKEN is set but no console is |
| +named". Not touched, outside this finding. Without the token the whole package passes. |
| + |
| +## F-2 deploy rollback of stable got the legacy refusal |
| +internal/console/apicli.go handleAPIRollback: immediately before the legacy stable block, when the channel is |
| +stable, `deployScopeAllows(p, false, channel, false)` runs and a refusal goes through `writeDeployRefusal`. |
| +It returns nil for every other principal, so people and publish credentials get the legacy 403 unchanged. The |
| +later full check after the pipeline read is unchanged. |
| +Test: TestDeployRollbackOfStableIsD22 (deployscope_test.go): deploy gets 403 with |
| +"deploy credential ci may move canary/<name>, edge only, and stable is not one of them." (no console_url), |
| +stable pointer still absent; person gets the legacy text with console_url. |
| + |
| +## F-3 fast-track regression |
| +TestDeployPromoteCannotFastTrack (deploysign_test.go): promote with `fast_track:true` and `fastTrack:true` as a |
| +deploy credential; both promote (200) and the verified edge pointer payload has FastTrack=false. |
| + |
| +## Runs (TMPDIR=/tmp/claude/fx, GOCACHE from original TMPDIR) |
| +- focused console tests: ok |
| +- `go test ./internal/console/ -count=1`: ok .../internal/console 50.510s |
| +- `go test . -count=1`: ok .../platform 225.343s |
| +- gofmt -l: clean |
diff --git a/e2e_test.go b/e2e_test.go| index 268f42903..977a38e5f 100644 |
| --- a/e2e_test.go |
| +++ b/e2e_test.go |
| @@ -82,7 +82,17 @@ func e2eGet(t *testing.T, base, host, path string) (int, string) { |
| func carlosTestEnv(t *testing.T, extra ...string) []string { |
| t.Helper() |
| scratch := t.TempDir() |
| - env := append(os.Environ(), |
| + // An inherited CARLOS_SERVICE_TOKEN (a CI shell that exports one) would |
| + // make any child that also sets CARLOS_DEPLOYMENT_DIR fail resolveMode's |
| + // token-plus-store refusal, so it is dropped here. It is filtered rather |
| + // than blanked: a test that passes its own token in extra still wins. |
| + var env []string |
| + for _, kv := range os.Environ() { |
| + if !strings.HasPrefix(kv, "CARLOS_SERVICE_TOKEN=") { |
| + env = append(env, kv) |
| + } |
| + } |
| + env = append(env, |
| "CARLOS_CREDENTIALS="+filepath.Join(scratch, "credentials"), |
| "HOME="+scratch, |
| "CARLOS_CONSOLE=", |
diff --git a/internal/console/apicli.go b/internal/console/apicli.go| index 17021d17d..5dba6cde8 100644 |
| --- a/internal/console/apicli.go |
| +++ b/internal/console/apicli.go |
| @@ -1029,6 +1029,17 @@ func (s *Server) handleAPIRollback(w http.ResponseWriter, r *http.Request) { |
| jsonError(w, http.StatusBadRequest, "invalid channel name") |
| return |
| } |
| + // A deploy credential never gets the legacy stable text below: it is not |
| + // a passkey it lacks, it is a rung it does not hold (spec §7.3, D22). |
| + // This early call only ever produces a refusal; for stable |
| + // deployScopeAllows refuses whatever the pipeline says, and the full check |
| + // after the pipeline read stays as it is. Every other principal gets nil. |
| + if rung, _ := release.Rung(channel); rung == release.RungStable { |
| + if err := deployScopeAllows(p, false, channel, false); err != nil { |
| + s.writeDeployRefusal(w, err) |
| + return |
| + } |
| + } |
| if rung, _ := release.Rung(channel); rung == release.RungStable { |
| writeJSON(w, http.StatusForbidden, map[string]string{ |
| "error": "stable needs a passkey — finish in the console", |
diff --git a/internal/console/deployscope_test.go b/internal/console/deployscope_test.go| index 292710d12..298a5d443 100644 |
| --- a/internal/console/deployscope_test.go |
| +++ b/internal/console/deployscope_test.go |
| @@ -140,3 +140,42 @@ func TestDeployScopeRefusalsAtTheDoors(t *testing.T) { |
| t.Errorf("rollback beta: channel moved %q -> %q", before, after) |
| } |
| } |
| + |
| +// TestDeployRollbackOfStableIsD22: a deploy credential rolling back stable |
| +// gets the D22 refusal naming it and its rungs, not the legacy passkey text, |
| +// and stable is left alone. A person's rollback of stable keeps the legacy |
| +// answer (the early deploy check returns nil for every other principal). |
| +func TestDeployRollbackOfStableIsD22(t *testing.T) { |
| + fx, person, deploy := deployFixture(t) |
| + stableAbsent := func() { |
| + t.Helper() |
| + if _, _, err := release.GetQualified(context.Background(), fx.rel, release.ChannelKey("bab", "hello", "stable")); err == nil { |
| + t.Fatal("stable pointer exists, want it untouched (absent)") |
| + } |
| + } |
| + |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/rollback", map[string]any{"channel": "stable"}, deploy) |
| + want := "deploy credential ci may move " + deployHeldWords([]string{"canary", "edge"}) + " only, and stable is not one of them." |
| + if status != http.StatusForbidden { |
| + t.Fatalf("deploy rollback stable = %d %v, want 403", status, out) |
| + } |
| + if got, _ := out["error"].(string); got != want { |
| + t.Errorf("deploy rollback stable error = %q, want %q", got, want) |
| + } |
| + if _, has := out["console_url"]; has { |
| + t.Errorf("deploy refusal carries the legacy console_url: %v", out) |
| + } |
| + stableAbsent() |
| + |
| + status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/rollback", map[string]any{"channel": "stable"}, person) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("person rollback stable = %d %v, want 403", status, out) |
| + } |
| + if got, _ := out["error"].(string); got != "stable needs a passkey — finish in the console" { |
| + t.Errorf("person rollback stable error = %q, want the legacy text", got) |
| + } |
| + if _, has := out["console_url"]; !has { |
| + t.Errorf("person refusal lost its console_url: %v", out) |
| + } |
| + stableAbsent() |
| +} |
diff --git a/internal/console/deploysign_test.go b/internal/console/deploysign_test.go| index 5ffa6df1d..94472451e 100644 |
| --- a/internal/console/deploysign_test.go |
| +++ b/internal/console/deploysign_test.go |
| @@ -224,3 +224,28 @@ func TestDeployRollback(t *testing.T) { |
| t.Fatalf("rolled-back pointer grant = %v, want name deploy.ci", g) |
| } |
| } |
| + |
| +// TestDeployPromoteCannotFastTrack pins spec §12: the console's promote door |
| +// has no fast-track input, so a deploy credential cannot waive the bake |
| +// window by sending one, under either spelling. Whatever status the request |
| +// gets, a pointer it wrote must not carry fast_track. |
| +func TestDeployPromoteCannotFastTrack(t *testing.T) { |
| + for _, key := range []string{"fast_track", "fastTrack"} { |
| + t.Run(key, func(t *testing.T) { |
| + fx, _, deploy := deployFixture(t) |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/promote", |
| + map[string]any{"version": "v1", "channel": "edge", key: true}, deploy) |
| + t.Logf("promote with %s = %d %v", key, status, out) |
| + cp, err := release.Channel(context.Background(), fx.rel, fx.pub, "bab", "hello", "edge") |
| + if err != nil { |
| + if status == http.StatusOK { |
| + t.Fatalf("promote answered 200 but edge does not verify: %v", err) |
| + } |
| + return // refused and nothing written: no pointer to carry the flag |
| + } |
| + if cp.FastTrack { |
| + t.Fatalf("edge pointer payload carries fast_track=true: %+v", cp) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/scopedkeys_e2e_test.go b/scopedkeys_e2e_test.go| index eb7df661b..cb9cdf358 100644 |
| --- a/scopedkeys_e2e_test.go |
| +++ b/scopedkeys_e2e_test.go |
| @@ -29,7 +29,17 @@ import ( |
| func carlosTestEnv(t *testing.T, extra ...string) []string { |
| t.Helper() |
| scratch := t.TempDir() |
| - env := append(os.Environ(), |
| + // An inherited CARLOS_SERVICE_TOKEN (a CI shell that exports one) would |
| + // make any child that also sets CARLOS_DEPLOYMENT_DIR fail resolveMode's |
| + // token-plus-store refusal, so it is dropped here. It is filtered rather |
| + // than blanked: a test that passes its own token in extra still wins. |
| + var env []string |
| + for _, kv := range os.Environ() { |
| + if !strings.HasPrefix(kv, "CARLOS_SERVICE_TOKEN=") { |
| + env = append(env, kv) |
| + } |
| + } |
| + env = append(env, |
| "CARLOS_CREDENTIALS="+filepath.Join(scratch, "credentials"), |
| "HOME="+scratch, |
| "CARLOS_CONSOLE=", |