| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "net/http" |
| 6 | "testing" |
| 7 | "time" |
| 8 | |
| 9 | "github.com/carlosframework/platform/internal/release" |
| 10 | "github.com/carlosframework/platform/internal/svcrole" |
| 11 | ) |
| 12 | |
| 13 | func TestDeployScopeAllows(t *testing.T) { |
| 14 | ce := Principal{Kind: PrincipalService, Role: svcrole.Deploy, Name: "ci", App: "hello", Rungs: []string{"canary", "edge"}} |
| 15 | beta := Principal{Kind: PrincipalService, Role: svcrole.Deploy, Name: "ci", App: "hello", Rungs: []string{"beta"}} |
| 16 | pub := Principal{Kind: PrincipalService, Role: svcrole.Publish, Name: "ci", App: "hello"} |
| 17 | person := Principal{Kind: PrincipalUser, Address: "paul@keymail.dev"} |
| 18 | for _, tc := range []struct { |
| 19 | name string |
| 20 | p Principal |
| 21 | pipelined bool |
| 22 | channel string |
| 23 | hotfix bool |
| 24 | ok bool |
| 25 | }{ |
| 26 | {"edge in scope", ce, false, "edge", false, true}, |
| 27 | {"canary branch in scope", ce, false, "canary/feature-x", false, true}, |
| 28 | {"bare canary refused", ce, false, "canary", false, false}, |
| 29 | {"bare canary refused on a pipelined app", ce, true, "canary", false, false}, |
| 30 | {"beta outside rungs", ce, false, "beta", false, false}, |
| 31 | {"stable refused", ce, false, "stable", false, false}, |
| 32 | {"stable refused even when granted beta", beta, false, "stable", false, false}, |
| 33 | {"not a rung", ce, false, "staging", false, false}, |
| 34 | {"hotfix refused", ce, false, "edge", true, false}, |
| 35 | {"pipelined: edge refused", ce, true, "edge", false, false}, |
| 36 | {"pipelined: canary branch allowed", ce, true, "canary/x", false, true}, |
| 37 | {"beta credential on beta", beta, false, "beta", false, true}, |
| 38 | {"publish untouched", pub, true, "stable", true, true}, |
| 39 | {"person untouched", person, true, "stable", true, true}, |
| 40 | } { |
| 41 | err := deployScopeAllows(tc.p, tc.pipelined, tc.channel, tc.hotfix) |
| 42 | if (err == nil) != tc.ok { |
| 43 | t.Errorf("%s: err = %v, want ok=%v", tc.name, err, tc.ok) |
| 44 | } |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | // TestDeployScopeRefusalsAtTheDoors: promote and rollback by a deploy |
| 49 | // credential refuse out of scope moves with the approved text (D21 to D24) |
| 50 | // and leave every channel exactly as it was. |
| 51 | func TestDeployScopeRefusalsAtTheDoors(t *testing.T) { |
| 52 | fx := newConsoleFixture(t) |
| 53 | if _, err := fx.st.CreateAppIn(OpsAccountID, "hello", "paul@keymail.dev", time.Now()); err != nil { |
| 54 | t.Fatal(err) |
| 55 | } |
| 56 | person := cliToken(t, fx, "paul@keymail.dev", "code-deploy-scope") |
| 57 | liftShipBudget(fx) |
| 58 | if status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/hello/releases", person, |
| 59 | []shipPart{metaPart(t, "v1", "binary"), {name: "linux-arm64", body: []byte("not an ELF at all")}}); status != http.StatusCreated { |
| 60 | t.Fatalf("person ship = %d %v", status, out) |
| 61 | } |
| 62 | tok, err := fx.st.NewServicePrincipalScoped(OpsAccountID, "ci", "hello", svcrole.Deploy, []string{"canary", "edge"}, nil, "paul@keymail.dev", time.Now()) |
| 63 | if err != nil { |
| 64 | t.Fatal(err) |
| 65 | } |
| 66 | |
| 67 | ctx := context.Background() |
| 68 | channelVersion := func(channel string) string { |
| 69 | t.Helper() |
| 70 | cp, err := release.Channel(ctx, fx.rel, fx.pub, "bab", "hello", channel) |
| 71 | if err != nil { |
| 72 | return "" // absent |
| 73 | } |
| 74 | return cp.Version |
| 75 | } |
| 76 | promote := func(body map[string]any) (int, map[string]any) { |
| 77 | t.Helper() |
| 78 | return postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/promote", body, tok) |
| 79 | } |
| 80 | refused := func(name string, status int, out map[string]any, want string) { |
| 81 | t.Helper() |
| 82 | if status != http.StatusForbidden { |
| 83 | t.Errorf("%s: status = %d %v, want 403", name, status, out) |
| 84 | return |
| 85 | } |
| 86 | if got, _ := out["error"].(string); got != want { |
| 87 | t.Errorf("%s: error = %q, want %q", name, got, want) |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | type attempt struct { |
| 92 | name string |
| 93 | body map[string]any |
| 94 | channel string |
| 95 | want string |
| 96 | } |
| 97 | d22 := func(channel string) string { |
| 98 | return "deploy credential ci may move canary/<name>, edge only, and " + channel + " is not one of them." |
| 99 | } |
| 100 | for _, a := range []attempt{ |
| 101 | {"beta", map[string]any{"version": "v1", "channel": "beta"}, "beta", d22("beta")}, |
| 102 | {"stable", map[string]any{"version": "v1", "channel": "stable"}, "stable", d22("stable")}, |
| 103 | {"bare canary", map[string]any{"version": "v1", "channel": "canary"}, "canary", |
| 104 | "deploy credential ci may move canary/<name> channels, not canary itself."}, |
| 105 | {"hotfix", map[string]any{"version": "v1", "channel": "edge", "hotfix": true}, "edge", |
| 106 | "deploy credential ci cannot hotfix. Promote in ladder order, or ask a member to hotfix."}, |
| 107 | } { |
| 108 | before := channelVersion(a.channel) |
| 109 | status, out := promote(a.body) |
| 110 | refused(a.name, status, out, a.want) |
| 111 | if after := channelVersion(a.channel); after != before { |
| 112 | t.Errorf("%s: channel %s moved %q -> %q", a.name, a.channel, before, after) |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | // A pipeline names edge and stable. Edge is then refused (D24), and a |
| 117 | // pipeline naming stable with no passkey rule still refuses deploy. |
| 118 | if _, err := release.SetPipeline(ctx, fx.rel, fx.priv, "bab", "hello", |
| 119 | []release.PipelineChannel{{Name: "edge"}, {Name: "stable"}}, "paul@keymail.dev"); err != nil { |
| 120 | t.Fatal(err) |
| 121 | } |
| 122 | for _, a := range []attempt{ |
| 123 | {"pipelined edge", map[string]any{"version": "v1", "channel": "edge"}, "edge", |
| 124 | "hello has a pipeline, so deploy credential ci may move canary/<name> channels only."}, |
| 125 | {"pipelined stable", map[string]any{"version": "v1", "channel": "stable"}, "stable", d22("stable")}, |
| 126 | } { |
| 127 | before := channelVersion(a.channel) |
| 128 | status, out := promote(a.body) |
| 129 | refused(a.name, status, out, a.want) |
| 130 | if after := channelVersion(a.channel); after != before { |
| 131 | t.Errorf("%s: channel %s moved %q -> %q", a.name, a.channel, before, after) |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | // Rollback: beta is outside the credential's rungs. |
| 136 | before := channelVersion("beta") |
| 137 | status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/rollback", map[string]any{"channel": "beta"}, tok) |
| 138 | refused("rollback beta", status, out, d22("beta")) |
| 139 | if after := channelVersion("beta"); after != before { |
| 140 | t.Errorf("rollback beta: channel moved %q -> %q", before, after) |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | // TestDeployRollbackOfStableIsD22: a deploy credential rolling back stable |
| 145 | // gets the D22 refusal naming it and its rungs, not the legacy passkey text, |
| 146 | // and stable is left alone. A person's rollback of stable keeps the legacy |
| 147 | // answer (the early deploy check returns nil for every other principal). |
| 148 | func TestDeployRollbackOfStableIsD22(t *testing.T) { |
| 149 | fx, person, deploy := deployFixture(t) |
| 150 | stableAbsent := func() { |
| 151 | t.Helper() |
| 152 | if _, _, err := release.GetQualified(context.Background(), fx.rel, release.ChannelKey("bab", "hello", "stable")); err == nil { |
| 153 | t.Fatal("stable pointer exists, want it untouched (absent)") |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/rollback", map[string]any{"channel": "stable"}, deploy) |
| 158 | want := "deploy credential ci may move " + deployHeldWords([]string{"canary", "edge"}) + " only, and stable is not one of them." |
| 159 | if status != http.StatusForbidden { |
| 160 | t.Fatalf("deploy rollback stable = %d %v, want 403", status, out) |
| 161 | } |
| 162 | if got, _ := out["error"].(string); got != want { |
| 163 | t.Errorf("deploy rollback stable error = %q, want %q", got, want) |
| 164 | } |
| 165 | if _, has := out["console_url"]; has { |
| 166 | t.Errorf("deploy refusal carries the legacy console_url: %v", out) |
| 167 | } |
| 168 | stableAbsent() |
| 169 | |
| 170 | status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/rollback", map[string]any{"channel": "stable"}, person) |
| 171 | if status != http.StatusForbidden { |
| 172 | t.Fatalf("person rollback stable = %d %v, want 403", status, out) |
| 173 | } |
| 174 | if got, _ := out["error"].(string); got != "stable needs a passkey — finish in the console" { |
| 175 | t.Errorf("person rollback stable error = %q, want the legacy text", got) |
| 176 | } |
| 177 | if _, has := out["console_url"]; !has { |
| 178 | t.Errorf("person refusal lost its console_url: %v", out) |
| 179 | } |
| 180 | stableAbsent() |
| 181 | } |
| 182 | |