package console import ( "context" "crypto/ed25519" "encoding/base64" "encoding/json" "errors" "net/http" "os" "strings" "testing" "time" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/svcrole" ) // deployFixture is a console with app hello in bab, v1 and v2 shipped by // paul, and a deploy credential ci over hello with rungs canary and edge. func deployFixture(t *testing.T) (fx *consoleFixture, person, deploy string) { t.Helper() 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-sign") liftShipBudget(fx) for _, v := range []string{"v1", "v2"} { status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/hello/releases", person, []shipPart{metaPart(t, v, "binary"), {name: "linux-arm64", body: []byte("bin " + v)}}) if status != http.StatusCreated { t.Fatalf("ship %s = %d %v", 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) } return fx, person, tok } // pointerGrant returns the decoded payload of the grant carried by a raw // channel pointer, or nil when the pointer is root-signed (no grant field). func pointerGrant(t *testing.T, fx *consoleFixture, channel string) map[string]any { t.Helper() raw, _, err := release.GetQualified(context.Background(), fx.rel, release.ChannelKey("bab", "hello", channel)) if err != nil { t.Fatalf("read %s pointer: %v", channel, err) } var wrap struct { Grant *struct { Payload string `json:"payload"` Sig string `json:"sig"` } `json:"grant"` } if err := json.Unmarshal(raw, &wrap); err != nil { t.Fatalf("pointer is not JSON: %v", err) } if wrap.Grant == nil { return nil } pl, err := base64.StdEncoding.DecodeString(wrap.Grant.Payload) if err != nil { pl, err = base64.RawURLEncoding.DecodeString(wrap.Grant.Payload) if err != nil { t.Fatalf("grant payload is not base64: %v", err) } } var g map[string]any if err := json.Unmarshal(pl, &g); err != nil { t.Fatalf("grant payload is not JSON: %v", err) } return g } func grantRungs(t *testing.T, g map[string]any) []string { t.Helper() var out []string rs, _ := g["rungs"].([]any) for _, r := range rs { s, _ := r.(string) out = append(out, s) } return out } func serialNow(t *testing.T, fx *consoleFixture) int64 { t.Helper() doc, _, err := release.ReadSerial(context.Background(), fx.rel) if err != nil { t.Fatal(err) } return doc.Serial } func TestDeployPromoteIsSignedByAScopedGrant(t *testing.T) { fx, _, deploy := deployFixture(t) before := serialNow(t, fx) status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/promote", map[string]any{"version": "v1", "channel": "edge"}, deploy) if status != http.StatusOK || str(t, out, "result") != "promoted" { t.Fatalf("deploy promote = %d %v", status, out) } if after := serialNow(t, fx); after != before+1 { t.Fatalf("serial %d -> %d, want +1", before, after) } evs, err := fx.st.Events(OpsAccountID, "hello", 10) if err != nil { t.Fatal(err) } found := false for _, e := range evs { if e.Action == "promote" { found = true } } if !found { t.Fatalf("no promote event in %v", evs) } cp, err := release.Channel(context.Background(), fx.rel, fx.pub, "bab", "hello", "edge") if err != nil { t.Fatalf("edge does not verify: %v", err) } if cp.Version != "v1" { t.Fatalf("edge = %q, want v1", cp.Version) } g := pointerGrant(t, fx, "edge") if g == nil { t.Fatal("edge pointer carries no grant") } if got := strings.Join(grantRungs(t, g), ","); got != "canary,edge" { t.Fatalf("grant rungs = %q, want canary,edge", got) } if g["name"] != "deploy.ci" { t.Fatalf("grant name = %v, want deploy.ci", g["name"]) } } func TestDeployRepeatIsANoOp(t *testing.T) { fx, _, deploy := deployFixture(t) body := map[string]any{"version": "v1", "channel": "edge"} if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/promote", body, deploy); status != http.StatusOK || str(t, out, "result") != "promoted" { t.Fatalf("first promote = %d %v", status, out) } before := serialNow(t, fx) status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/promote", body, deploy) if status != http.StatusOK || str(t, out, "result") != "noop" { t.Fatalf("repeat promote = %d %v, want 200 noop", status, out) } if after := serialNow(t, fx); after != before { t.Fatalf("serial %d -> %d on a no-op", before, after) } } func TestDeploySignerRefusesWithoutTheScopeFunction(t *testing.T) { fx, _, _ := deployFixture(t) sg, err := fx.srv.deploySigner(Principal{Kind: PrincipalService, Role: svcrole.Deploy, Name: "ci", App: "hello", Rungs: []string{"edge"}}) if err != nil { t.Fatal(err) } ctx := context.Background() for _, ch := range []string{"beta", "stable"} { if _, err := sg.PromoteOnLadder(ctx, fx.rel, "bab", "hello", ch, "v1", release.PromoteOptions{Hotfix: true}); err == nil { t.Errorf("signer promoted %s with no Authorize hook", ch) } } } func TestDeploySignerFailureFailsClosed(t *testing.T) { fx, _, deploy := deployFixture(t) pub, _, err := ed25519.GenerateKey(nil) if err != nil { t.Fatal(err) } fx.srv.cfg.ReleasePub = pub status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/promote", map[string]any{"version": "v1", "channel": "edge"}, deploy) if status != http.StatusServiceUnavailable { t.Fatalf("promote = %d %v, want 503", status, out) } if got := str(t, out, "error"); got != "could not prepare a signing key for this deploy credential. Nothing was promoted." { t.Fatalf("error = %q", got) } if _, _, err := release.GetQualified(context.Background(), fx.rel, release.ChannelKey("bab", "hello", "edge")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("edge pointer after a failed signer: err = %v, want ErrNotExist", err) } } func TestPersonPromoteUnchanged(t *testing.T) { fx, person, _ := deployFixture(t) status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/promote", map[string]any{"version": "v1", "channel": "edge"}, person) if status != http.StatusOK { t.Fatalf("person promote = %d %v", status, out) } if g := pointerGrant(t, fx, "edge"); g != nil { t.Fatalf("person-signed pointer carries a grant: %v", g) } } func TestDeployRollback(t *testing.T) { fx, person, deploy := deployFixture(t) for _, v := range []string{"v1", "v2"} { if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/promote", map[string]any{"version": v, "channel": "edge"}, person); status != http.StatusOK { t.Fatalf("person promote %s = %d %v", v, status, out) } } status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/rollback", map[string]any{"channel": "edge"}, deploy) if status != http.StatusOK || str(t, out, "result") != "rolled back" { t.Fatalf("deploy rollback = %d %v", status, out) } cp, err := release.Channel(context.Background(), fx.rel, fx.pub, "bab", "hello", "edge") if err != nil { t.Fatal(err) } if cp.Version != "v1" { t.Fatalf("edge = %q, want v1", cp.Version) } g := pointerGrant(t, fx, "edge") if g == nil || g["name"] != "deploy.ci" { 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) } }) } } // TestDeployRollbackSignerFailureSaysRolledBack: when the request-local // signer cannot be built on a rollback, the 503 says what did not happen — // a rollback (approved copy D26r), not D26's "Nothing was promoted." The // signer is made to fail with a credential name the grant format refuses; // only the store's own non-empty check stands between such a row and the // door, and the mint doors never write one. func TestDeployRollbackSignerFailureSaysRolledBack(t *testing.T) { fx, person, _ := deployFixture(t) for _, v := range []string{"v1", "v2"} { if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/promote", map[string]any{"version": v, "channel": "edge"}, person); status != http.StatusOK { t.Fatalf("person promote %s = %d %v", v, status, out) } } bad, err := fx.st.NewServicePrincipalScoped(OpsAccountID, "bad name!", "hello", svcrole.Deploy, []string{"canary", "edge"}, nil, "paul@keymail.dev", time.Now()) if err != nil { t.Fatal(err) } status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/rollback", map[string]any{"channel": "edge"}, bad) const want = "could not prepare a signing key for this deploy credential. Nothing was rolled back." if status != http.StatusServiceUnavailable || str(t, out, "error") != want { t.Fatalf("rollback with a failing signer = %d %v, want 503 %q", status, out, want) } cp, err := release.Channel(context.Background(), fx.rel, fx.pub, "bab", "hello", "edge") if err != nil { t.Fatal(err) } if cp.Version != "v2" { t.Fatalf("edge = %q after a refused rollback, want v2", cp.Version) } }