| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/ed25519" |
| 6 | "encoding/base64" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "net/http" |
| 10 | "os" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | "time" |
| 14 | |
| 15 | "github.com/carlosframework/platform/internal/release" |
| 16 | "github.com/carlosframework/platform/internal/svcrole" |
| 17 | ) |
| 18 | |
| 19 | // deployFixture is a console with app hello in bab, v1 and v2 shipped by |
| 20 | // paul, and a deploy credential ci over hello with rungs canary and edge. |
| 21 | func deployFixture(t *testing.T) (fx *consoleFixture, person, deploy string) { |
| 22 | t.Helper() |
| 23 | fx = newConsoleFixture(t) |
| 24 | if _, err := fx.st.CreateAppIn(OpsAccountID, "hello", "paul@keymail.dev", time.Now()); err != nil { |
| 25 | t.Fatal(err) |
| 26 | } |
| 27 | person = cliToken(t, fx, "paul@keymail.dev", "code-deploy-sign") |
| 28 | liftShipBudget(fx) |
| 29 | for _, v := range []string{"v1", "v2"} { |
| 30 | status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/hello/releases", person, |
| 31 | []shipPart{metaPart(t, v, "binary"), {name: "linux-arm64", body: []byte("bin " + v)}}) |
| 32 | if status != http.StatusCreated { |
| 33 | t.Fatalf("ship %s = %d %v", v, status, out) |
| 34 | } |
| 35 | } |
| 36 | tok, err := fx.st.NewServicePrincipalScoped(OpsAccountID, "ci", "hello", svcrole.Deploy, []string{"canary", "edge"}, nil, "paul@keymail.dev", time.Now()) |
| 37 | if err != nil { |
| 38 | t.Fatal(err) |
| 39 | } |
| 40 | return fx, person, tok |
| 41 | } |
| 42 | |
| 43 | // pointerGrant returns the decoded payload of the grant carried by a raw |
| 44 | // channel pointer, or nil when the pointer is root-signed (no grant field). |
| 45 | func pointerGrant(t *testing.T, fx *consoleFixture, channel string) map[string]any { |
| 46 | t.Helper() |
| 47 | raw, _, err := release.GetQualified(context.Background(), fx.rel, release.ChannelKey("bab", "hello", channel)) |
| 48 | if err != nil { |
| 49 | t.Fatalf("read %s pointer: %v", channel, err) |
| 50 | } |
| 51 | var wrap struct { |
| 52 | Grant *struct { |
| 53 | Payload string `json:"payload"` |
| 54 | Sig string `json:"sig"` |
| 55 | } `json:"grant"` |
| 56 | } |
| 57 | if err := json.Unmarshal(raw, &wrap); err != nil { |
| 58 | t.Fatalf("pointer is not JSON: %v", err) |
| 59 | } |
| 60 | if wrap.Grant == nil { |
| 61 | return nil |
| 62 | } |
| 63 | pl, err := base64.StdEncoding.DecodeString(wrap.Grant.Payload) |
| 64 | if err != nil { |
| 65 | pl, err = base64.RawURLEncoding.DecodeString(wrap.Grant.Payload) |
| 66 | if err != nil { |
| 67 | t.Fatalf("grant payload is not base64: %v", err) |
| 68 | } |
| 69 | } |
| 70 | var g map[string]any |
| 71 | if err := json.Unmarshal(pl, &g); err != nil { |
| 72 | t.Fatalf("grant payload is not JSON: %v", err) |
| 73 | } |
| 74 | return g |
| 75 | } |
| 76 | |
| 77 | func grantRungs(t *testing.T, g map[string]any) []string { |
| 78 | t.Helper() |
| 79 | var out []string |
| 80 | rs, _ := g["rungs"].([]any) |
| 81 | for _, r := range rs { |
| 82 | s, _ := r.(string) |
| 83 | out = append(out, s) |
| 84 | } |
| 85 | return out |
| 86 | } |
| 87 | |
| 88 | func serialNow(t *testing.T, fx *consoleFixture) int64 { |
| 89 | t.Helper() |
| 90 | doc, _, err := release.ReadSerial(context.Background(), fx.rel) |
| 91 | if err != nil { |
| 92 | t.Fatal(err) |
| 93 | } |
| 94 | return doc.Serial |
| 95 | } |
| 96 | |
| 97 | func TestDeployPromoteIsSignedByAScopedGrant(t *testing.T) { |
| 98 | fx, _, deploy := deployFixture(t) |
| 99 | before := serialNow(t, fx) |
| 100 | status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/promote", |
| 101 | map[string]any{"version": "v1", "channel": "edge"}, deploy) |
| 102 | if status != http.StatusOK || str(t, out, "result") != "promoted" { |
| 103 | t.Fatalf("deploy promote = %d %v", status, out) |
| 104 | } |
| 105 | if after := serialNow(t, fx); after != before+1 { |
| 106 | t.Fatalf("serial %d -> %d, want +1", before, after) |
| 107 | } |
| 108 | evs, err := fx.st.Events(OpsAccountID, "hello", 10) |
| 109 | if err != nil { |
| 110 | t.Fatal(err) |
| 111 | } |
| 112 | found := false |
| 113 | for _, e := range evs { |
| 114 | if e.Action == "promote" { |
| 115 | found = true |
| 116 | } |
| 117 | } |
| 118 | if !found { |
| 119 | t.Fatalf("no promote event in %v", evs) |
| 120 | } |
| 121 | cp, err := release.Channel(context.Background(), fx.rel, fx.pub, "bab", "hello", "edge") |
| 122 | if err != nil { |
| 123 | t.Fatalf("edge does not verify: %v", err) |
| 124 | } |
| 125 | if cp.Version != "v1" { |
| 126 | t.Fatalf("edge = %q, want v1", cp.Version) |
| 127 | } |
| 128 | g := pointerGrant(t, fx, "edge") |
| 129 | if g == nil { |
| 130 | t.Fatal("edge pointer carries no grant") |
| 131 | } |
| 132 | if got := strings.Join(grantRungs(t, g), ","); got != "canary,edge" { |
| 133 | t.Fatalf("grant rungs = %q, want canary,edge", got) |
| 134 | } |
| 135 | if g["name"] != "deploy.ci" { |
| 136 | t.Fatalf("grant name = %v, want deploy.ci", g["name"]) |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | func TestDeployRepeatIsANoOp(t *testing.T) { |
| 141 | fx, _, deploy := deployFixture(t) |
| 142 | body := map[string]any{"version": "v1", "channel": "edge"} |
| 143 | if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/promote", body, deploy); status != http.StatusOK || str(t, out, "result") != "promoted" { |
| 144 | t.Fatalf("first promote = %d %v", status, out) |
| 145 | } |
| 146 | before := serialNow(t, fx) |
| 147 | status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/promote", body, deploy) |
| 148 | if status != http.StatusOK || str(t, out, "result") != "noop" { |
| 149 | t.Fatalf("repeat promote = %d %v, want 200 noop", status, out) |
| 150 | } |
| 151 | if after := serialNow(t, fx); after != before { |
| 152 | t.Fatalf("serial %d -> %d on a no-op", before, after) |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | func TestDeploySignerRefusesWithoutTheScopeFunction(t *testing.T) { |
| 157 | fx, _, _ := deployFixture(t) |
| 158 | sg, err := fx.srv.deploySigner(Principal{Kind: PrincipalService, Role: svcrole.Deploy, Name: "ci", App: "hello", Rungs: []string{"edge"}}) |
| 159 | if err != nil { |
| 160 | t.Fatal(err) |
| 161 | } |
| 162 | ctx := context.Background() |
| 163 | for _, ch := range []string{"beta", "stable"} { |
| 164 | if _, err := sg.PromoteOnLadder(ctx, fx.rel, "bab", "hello", ch, "v1", release.PromoteOptions{Hotfix: true}); err == nil { |
| 165 | t.Errorf("signer promoted %s with no Authorize hook", ch) |
| 166 | } |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | func TestDeploySignerFailureFailsClosed(t *testing.T) { |
| 171 | fx, _, deploy := deployFixture(t) |
| 172 | pub, _, err := ed25519.GenerateKey(nil) |
| 173 | if err != nil { |
| 174 | t.Fatal(err) |
| 175 | } |
| 176 | fx.srv.cfg.ReleasePub = pub |
| 177 | status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/promote", |
| 178 | map[string]any{"version": "v1", "channel": "edge"}, deploy) |
| 179 | if status != http.StatusServiceUnavailable { |
| 180 | t.Fatalf("promote = %d %v, want 503", status, out) |
| 181 | } |
| 182 | if got := str(t, out, "error"); got != "could not prepare a signing key for this deploy credential. Nothing was promoted." { |
| 183 | t.Fatalf("error = %q", got) |
| 184 | } |
| 185 | if _, _, err := release.GetQualified(context.Background(), fx.rel, release.ChannelKey("bab", "hello", "edge")); !errors.Is(err, os.ErrNotExist) { |
| 186 | t.Fatalf("edge pointer after a failed signer: err = %v, want ErrNotExist", err) |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | func TestPersonPromoteUnchanged(t *testing.T) { |
| 191 | fx, person, _ := deployFixture(t) |
| 192 | status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/promote", |
| 193 | map[string]any{"version": "v1", "channel": "edge"}, person) |
| 194 | if status != http.StatusOK { |
| 195 | t.Fatalf("person promote = %d %v", status, out) |
| 196 | } |
| 197 | if g := pointerGrant(t, fx, "edge"); g != nil { |
| 198 | t.Fatalf("person-signed pointer carries a grant: %v", g) |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | func TestDeployRollback(t *testing.T) { |
| 203 | fx, person, deploy := deployFixture(t) |
| 204 | for _, v := range []string{"v1", "v2"} { |
| 205 | if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/promote", |
| 206 | map[string]any{"version": v, "channel": "edge"}, person); status != http.StatusOK { |
| 207 | t.Fatalf("person promote %s = %d %v", v, status, out) |
| 208 | } |
| 209 | } |
| 210 | status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/rollback", |
| 211 | map[string]any{"channel": "edge"}, deploy) |
| 212 | if status != http.StatusOK || str(t, out, "result") != "rolled back" { |
| 213 | t.Fatalf("deploy rollback = %d %v", status, out) |
| 214 | } |
| 215 | cp, err := release.Channel(context.Background(), fx.rel, fx.pub, "bab", "hello", "edge") |
| 216 | if err != nil { |
| 217 | t.Fatal(err) |
| 218 | } |
| 219 | if cp.Version != "v1" { |
| 220 | t.Fatalf("edge = %q, want v1", cp.Version) |
| 221 | } |
| 222 | g := pointerGrant(t, fx, "edge") |
| 223 | if g == nil || g["name"] != "deploy.ci" { |
| 224 | t.Fatalf("rolled-back pointer grant = %v, want name deploy.ci", g) |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | // TestDeployPromoteCannotFastTrack pins spec §12: the console's promote door |
| 229 | // has no fast-track input, so a deploy credential cannot waive the bake |
| 230 | // window by sending one, under either spelling. Whatever status the request |
| 231 | // gets, a pointer it wrote must not carry fast_track. |
| 232 | func TestDeployPromoteCannotFastTrack(t *testing.T) { |
| 233 | for _, key := range []string{"fast_track", "fastTrack"} { |
| 234 | t.Run(key, func(t *testing.T) { |
| 235 | fx, _, deploy := deployFixture(t) |
| 236 | status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/promote", |
| 237 | map[string]any{"version": "v1", "channel": "edge", key: true}, deploy) |
| 238 | t.Logf("promote with %s = %d %v", key, status, out) |
| 239 | cp, err := release.Channel(context.Background(), fx.rel, fx.pub, "bab", "hello", "edge") |
| 240 | if err != nil { |
| 241 | if status == http.StatusOK { |
| 242 | t.Fatalf("promote answered 200 but edge does not verify: %v", err) |
| 243 | } |
| 244 | return // refused and nothing written: no pointer to carry the flag |
| 245 | } |
| 246 | if cp.FastTrack { |
| 247 | t.Fatalf("edge pointer payload carries fast_track=true: %+v", cp) |
| 248 | } |
| 249 | }) |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | // TestDeployRollbackSignerFailureSaysRolledBack: when the request-local |
| 254 | // signer cannot be built on a rollback, the 503 says what did not happen — |
| 255 | // a rollback (approved copy D26r), not D26's "Nothing was promoted." The |
| 256 | // signer is made to fail with a credential name the grant format refuses; |
| 257 | // only the store's own non-empty check stands between such a row and the |
| 258 | // door, and the mint doors never write one. |
| 259 | func TestDeployRollbackSignerFailureSaysRolledBack(t *testing.T) { |
| 260 | fx, person, _ := deployFixture(t) |
| 261 | for _, v := range []string{"v1", "v2"} { |
| 262 | if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/promote", |
| 263 | map[string]any{"version": v, "channel": "edge"}, person); status != http.StatusOK { |
| 264 | t.Fatalf("person promote %s = %d %v", v, status, out) |
| 265 | } |
| 266 | } |
| 267 | bad, err := fx.st.NewServicePrincipalScoped(OpsAccountID, "bad name!", "hello", svcrole.Deploy, |
| 268 | []string{"canary", "edge"}, nil, "paul@keymail.dev", time.Now()) |
| 269 | if err != nil { |
| 270 | t.Fatal(err) |
| 271 | } |
| 272 | status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/rollback", |
| 273 | map[string]any{"channel": "edge"}, bad) |
| 274 | const want = "could not prepare a signing key for this deploy credential. Nothing was rolled back." |
| 275 | if status != http.StatusServiceUnavailable || str(t, out, "error") != want { |
| 276 | t.Fatalf("rollback with a failing signer = %d %v, want 503 %q", status, out, want) |
| 277 | } |
| 278 | cp, err := release.Channel(context.Background(), fx.rel, fx.pub, "bab", "hello", "edge") |
| 279 | if err != nil { |
| 280 | t.Fatal(err) |
| 281 | } |
| 282 | if cp.Version != "v2" { |
| 283 | t.Fatalf("edge = %q after a refused rollback, want v2", cp.Version) |
| 284 | } |
| 285 | } |
| 286 | |