| new file mode 100644 |
| index 000000000..292710d12 |
| --- /dev/null |
| +++ b/internal/console/deployscope_test.go |
| @@ -0,0 +1,142 @@ |
| +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"}, "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/<name>, 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/<name> 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/<name> 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) |
| + } |
| +} |