package console import ( "context" "net/http" "testing" "time" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/svcrole" ) func TestDeployScopeAllows(t *testing.T) { ce := Principal{Kind: PrincipalService, Role: svcrole.Deploy, Name: "ci", App: "hello", Rungs: []string{"canary", "edge"}} beta := Principal{Kind: PrincipalService, Role: svcrole.Deploy, Name: "ci", App: "hello", Rungs: []string{"beta"}} pub := Principal{Kind: PrincipalService, Role: svcrole.Publish, Name: "ci", App: "hello"} person := Principal{Kind: PrincipalUser, Address: "paul@keymail.dev"} for _, tc := range []struct { name string p Principal pipelined bool channel string hotfix bool ok bool }{ {"edge in scope", ce, false, "edge", false, true}, {"canary branch in scope", ce, false, "canary/feature-x", false, true}, {"bare canary refused", ce, false, "canary", false, false}, {"bare canary refused on a pipelined app", ce, true, "canary", false, false}, {"beta outside rungs", ce, false, "beta", false, false}, {"stable refused", ce, false, "stable", false, false}, {"stable refused even when granted beta", beta, false, "stable", false, false}, {"not a rung", ce, false, "staging", false, false}, {"hotfix refused", ce, false, "edge", true, false}, {"pipelined: edge refused", ce, true, "edge", false, false}, {"pipelined: canary branch allowed", ce, true, "canary/x", false, true}, {"beta credential on beta", beta, false, "beta", false, true}, {"publish untouched", pub, true, "stable", true, true}, {"person untouched", person, true, "stable", true, true}, } { err := deployScopeAllows(tc.p, tc.pipelined, tc.channel, tc.hotfix) if (err == nil) != tc.ok { t.Errorf("%s: err = %v, want ok=%v", tc.name, err, tc.ok) } } } // TestDeployScopeRefusalsAtTheDoors: promote and rollback by a deploy // credential refuse out of scope moves with the approved text (D21 to D24) // and leave every channel exactly as it was. func TestDeployScopeRefusalsAtTheDoors(t *testing.T) { fx := newConsoleFixture(t) if _, err := fx.st.CreateAppIn(OpsAccountID, "hello", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } person := cliToken(t, fx, "paul@keymail.dev", "code-deploy-scope") liftShipBudget(fx) if status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/hello/releases", person, []shipPart{metaPart(t, "v1", "binary"), {name: "linux-arm64", body: []byte("not an ELF at all")}}); status != http.StatusCreated { t.Fatalf("person ship = %d %v", status, out) } tok, err := fx.st.NewServicePrincipalScoped(OpsAccountID, "ci", "hello", svcrole.Deploy, []string{"canary", "edge"}, nil, "paul@keymail.dev", time.Now()) if err != nil { t.Fatal(err) } ctx := context.Background() channelVersion := func(channel string) string { t.Helper() cp, err := release.Channel(ctx, fx.rel, fx.pub, "bab", "hello", channel) if err != nil { return "" // absent } return cp.Version } promote := func(body map[string]any) (int, map[string]any) { t.Helper() return postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/promote", body, tok) } refused := func(name string, status int, out map[string]any, want string) { t.Helper() if status != http.StatusForbidden { t.Errorf("%s: status = %d %v, want 403", name, status, out) return } if got, _ := out["error"].(string); got != want { t.Errorf("%s: error = %q, want %q", name, got, want) } } type attempt struct { name string body map[string]any channel string want string } d22 := func(channel string) string { return "deploy credential ci may move canary/, edge only, and " + channel + " is not one of them." } for _, a := range []attempt{ {"beta", map[string]any{"version": "v1", "channel": "beta"}, "beta", d22("beta")}, {"stable", map[string]any{"version": "v1", "channel": "stable"}, "stable", d22("stable")}, {"bare canary", map[string]any{"version": "v1", "channel": "canary"}, "canary", "deploy credential ci may move canary/ channels, not canary itself."}, {"hotfix", map[string]any{"version": "v1", "channel": "edge", "hotfix": true}, "edge", "deploy credential ci cannot hotfix. Promote in ladder order, or ask a member to hotfix."}, } { before := channelVersion(a.channel) status, out := promote(a.body) refused(a.name, status, out, a.want) if after := channelVersion(a.channel); after != before { t.Errorf("%s: channel %s moved %q -> %q", a.name, a.channel, before, after) } } // A pipeline names edge and stable. Edge is then refused (D24), and a // pipeline naming stable with no passkey rule still refuses deploy. if _, err := release.SetPipeline(ctx, fx.rel, fx.priv, "bab", "hello", []release.PipelineChannel{{Name: "edge"}, {Name: "stable"}}, "paul@keymail.dev"); err != nil { t.Fatal(err) } for _, a := range []attempt{ {"pipelined edge", map[string]any{"version": "v1", "channel": "edge"}, "edge", "hello has a pipeline, so deploy credential ci may move canary/ channels only."}, {"pipelined stable", map[string]any{"version": "v1", "channel": "stable"}, "stable", d22("stable")}, } { before := channelVersion(a.channel) status, out := promote(a.body) refused(a.name, status, out, a.want) if after := channelVersion(a.channel); after != before { t.Errorf("%s: channel %s moved %q -> %q", a.name, a.channel, before, after) } } // Rollback: beta is outside the credential's rungs. before := channelVersion("beta") status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/rollback", map[string]any{"channel": "beta"}, tok) refused("rollback beta", status, out, d22("beta")) if after := channelVersion("beta"); after != before { 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() }