package console import ( "net/http" "net/url" "strings" "testing" "time" ) func TestAPIMintDeploy(t *testing.T) { fx := newConsoleFixture(t) if _, err := fx.st.CreateAppIn(OpsAccountID, "hello", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } token := cliToken(t, fx, "paul@keymail.dev", "code-deploy-mint") u := fx.ts.URL + "/api/cli/services/bab" status, out := postJSON(t, u, map[string]any{"name": "ci", "role": "deploy", "app": "hello", "rungs": []string{"edge", "canary"}}, token) if status != http.StatusCreated { t.Fatalf("mint deploy = %d %v", status, out) } if got, _ := out["rungs"].([]any); len(got) != 2 || got[0] != "canary" || got[1] != "edge" { t.Fatalf("rungs in response = %v", out["rungs"]) } for _, tc := range []struct { body map[string]any want string // D-text fragment from the approved Copy table }{ {map[string]any{"name": "a1", "role": "deploy", "rungs": []string{"edge"}}, "must name one app"}, {map[string]any{"name": "a2", "role": "deploy", "app": "hello"}, "must name its rungs"}, {map[string]any{"name": "a3", "role": "deploy", "app": "hello", "rungs": []string{"stable"}}, "stable cannot be delegated"}, {map[string]any{"name": "a4", "role": "deploy", "app": "hello", "rungs": []string{"prod"}}, `"prod"`}, {map[string]any{"name": "a5", "role": "publish", "app": "hello", "rungs": []string{"edge"}}, "only a deploy credential"}, } { status, out := postJSON(t, u, tc.body, token) if status != http.StatusBadRequest || !strings.Contains(str(t, out, "error"), tc.want) { t.Errorf("%v = %d %v; want 400 containing %q", tc.body, status, out, tc.want) } } status, out = getJSON(t, u, token) if status != http.StatusOK { t.Fatalf("list = %d %v", status, out) } rows, _ := out["services"].([]any) if len(rows) != 1 { t.Fatalf("listing = %v", rows) } if r, _ := rows[0].(map[string]any)["rungs"].([]any); len(r) != 2 { t.Fatalf("listing rungs = %v", rows[0]) } } // A non-deploy row lists "rungs": [] (an empty array, never null), so a // client can range over it without a nil check. func TestAPIListNonDeployRungsAreAnEmptyArray(t *testing.T) { fx := newConsoleFixture(t) if _, err := fx.st.CreateAppIn(OpsAccountID, "hello", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } token := cliToken(t, fx, "paul@keymail.dev", "code-deploy-list") u := fx.ts.URL + "/api/cli/services/bab" if status, out := postJSON(t, u, map[string]any{"name": "pub", "role": "publish", "app": "hello"}, token); status != http.StatusCreated { t.Fatalf("mint publish = %d %v", status, out) } else if r, ok := out["rungs"].([]any); !ok || len(r) != 0 { t.Fatalf("mint response rungs = %#v, want []", out["rungs"]) } status, out := getJSON(t, u, token) if status != http.StatusOK { t.Fatalf("list = %d %v", status, out) } rows, _ := out["services"].([]any) if len(rows) != 1 { t.Fatalf("listing = %v", rows) } if r, ok := rows[0].(map[string]any)["rungs"].([]any); !ok || len(r) != 0 { t.Fatalf("listing rungs = %#v, want []", rows[0].(map[string]any)["rungs"]) } } func TestFormMintDeploy(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) before, err := fx.st.ServicePrincipals(OpsAccountID) if err != nil { t.Fatal(err) } // Each refusal is a flash on the settings page, before any passkey trip // and with no row created. for _, bad := range []struct { why string form url.Values want string }{ {"no app", url.Values{"csrf": {csrf}, "name": {"ci"}, "role": {"deploy"}, "rungs": {"edge"}}, "A deploy credential must name one app. Nothing was minted."}, {"no rungs", url.Values{"csrf": {csrf}, "name": {"ci"}, "role": {"deploy"}, "app": {"kass"}}, "A deploy credential must name at least one rung: canary, edge or beta. Nothing was minted."}, {"stable", url.Values{"csrf": {csrf}, "name": {"ci"}, "role": {"deploy"}, "app": {"kass"}, "rungs": {"edge", "stable"}}, "Stable cannot be delegated. Moving it needs a passkey in the console. Nothing was minted."}, {"an unknown rung", url.Values{"csrf": {csrf}, "name": {"ci"}, "role": {"deploy"}, "app": {"kass"}, "rungs": {"prod"}}, "is not a rung. Choose canary, edge or beta. Nothing was minted."}, {"rungs on publish", url.Values{"csrf": {csrf}, "name": {"ci"}, "role": {"publish"}, "app": {"kass"}, "rungs": {"edge"}}, "Only a deploy credential takes rungs. Nothing was minted."}, } { status, loc, body := postForm(t, c, fx.u("/services/new"), bad.form) if strings.HasPrefix(loc, fx.fk.srv.URL) { t.Errorf("%s cost a passkey trip (-> %q)", bad.why, loc) } if status != http.StatusSeeOther || loc != fx.path("/settings") { t.Errorf("%s = %d -> %q, want 303 back to the page:\n%s", bad.why, status, loc, body) continue } _, _, page := get(t, c, fx.u("/settings")) if !strings.Contains(page, bad.want) { t.Errorf("%s: the settings page does not say %q:\n%s", bad.why, bad.want, page) } rows, err := fx.st.ServicePrincipals(OpsAccountID) if err != nil || len(rows) != len(before) { t.Errorf("%s: ServicePrincipals = %d rows %v, want %d (nothing minted)", bad.why, len(rows), err, len(before)) } } status, _, body := postGated(t, fx, c, fx.u("/services/new"), url.Values{"csrf": {csrf}, "name": {"ci"}, "role": {"deploy"}, "app": {"kass"}, "rungs": {"edge", "canary"}}) if status != http.StatusOK { t.Fatalf("deploy mint = %d:\n%s", status, body) } if !strings.Contains(body, "rungs canary, edge") { t.Fatalf("the secret banner does not name the rungs:\n%s", body) } secret := paneSecret(t, body) p, ok := fx.st.CLIPrincipal(secret, time.Now()) if !ok || strings.Join(p.Rungs, ",") != "canary,edge" { t.Fatalf("minted credential resolves to %+v, %v; want rungs canary,edge", p, ok) } // The listing shows the rungs too. _, _, page := get(t, c, fx.u("/settings")) if !strings.Contains(page, "canary, edge") { t.Fatalf("the credential listing does not show the rungs:\n%s", page) } }