| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "net/http" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/release" |
| 13 | ) |
| 14 | |
| 15 | // TestAuthorizeDoorShowsThenSigns: an owner of the ops account (paul) reads |
| 16 | // the plan and signs exactly the digest he confirmed (R19). |
| 17 | func TestAuthorizeDoorShowsThenSigns(t *testing.T) { |
| 18 | fx := newDigestsFixture(t) |
| 19 | if err := fx.store.Delete(context.Background(), release.PromotionRecordKey("bab", "km", "v1")); err != nil { |
| 20 | t.Fatal(err) |
| 21 | } |
| 22 | st, out := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/releases/v1/authorize", fx.operator) |
| 23 | if st != http.StatusOK { |
| 24 | t.Fatalf("plan: %d %s", st, out) |
| 25 | } |
| 26 | var plan struct { |
| 27 | ManifestDigest string `json:"manifest_digest"` |
| 28 | } |
| 29 | if err := json.Unmarshal([]byte(out), &plan); err != nil || plan.ManifestDigest == "" { |
| 30 | t.Fatalf("plan body %s: %v", out, err) |
| 31 | } |
| 32 | st, out = postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/releases/v1/authorize", map[string]string{"manifest_digest": "00"}, fx.operator) |
| 33 | if st != http.StatusConflict { |
| 34 | t.Fatalf("a confirmation for other bytes: %d %s", st, out) |
| 35 | } |
| 36 | st, out = postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/releases/v1/authorize", map[string]string{"manifest_digest": plan.ManifestDigest}, fx.operator) |
| 37 | if st != http.StatusOK { |
| 38 | t.Fatalf("sign: %d %s", st, out) |
| 39 | } |
| 40 | if rec, err := release.ReadPromotionRecord(context.Background(), fx.store, fx.pub, "bab", "km", "v1"); err != nil || !rec.Authorized { |
| 41 | t.Fatalf("record = %+v, %v", rec, err) |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | func TestAuthorizeDoorRefusesServiceCredentials(t *testing.T) { |
| 46 | fx := newDigestsFixture(t) |
| 47 | st, _ := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/releases/v1/authorize", fx.serviceToken) |
| 48 | if st != http.StatusNotFound && st != http.StatusForbidden { |
| 49 | t.Fatalf("a service credential reached authorize: %d", st) |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | // TestAuthorizeDoorSpendsTheWriteBudget: the POST signs a record inside the |
| 54 | // app's exclusive mutation, so it draws on the per-person write budget, like |
| 55 | // resign, before any store work. The GET is a read and stays unbudgeted. |
| 56 | func TestAuthorizeDoorSpendsTheWriteBudget(t *testing.T) { |
| 57 | fx := newDigestsFixture(t) |
| 58 | if err := fx.store.Delete(context.Background(), release.PromotionRecordKey("bab", "km", "v1")); err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | fx.srv.cli.write = newRateLimiter(1, time.Minute) |
| 62 | u := fx.ts.URL + "/api/cli/apps/bab/km/releases/v1/authorize" |
| 63 | st, out := getRaw(t, u, fx.operator) |
| 64 | if st != http.StatusOK { |
| 65 | t.Fatalf("plan: %d %s", st, out) |
| 66 | } |
| 67 | var plan struct { |
| 68 | ManifestDigest string `json:"manifest_digest"` |
| 69 | } |
| 70 | if err := json.Unmarshal([]byte(out), &plan); err != nil || plan.ManifestDigest == "" { |
| 71 | t.Fatalf("plan body %s: %v", out, err) |
| 72 | } |
| 73 | if st, out := postRaw(t, u, map[string]string{"manifest_digest": "00"}, fx.operator); st != http.StatusConflict { |
| 74 | t.Fatalf("first request: %d %s", st, out) |
| 75 | } |
| 76 | if st, out := getRaw(t, u, fx.operator); st != http.StatusOK { |
| 77 | t.Fatalf("a read after the budget is spent: %d %s", st, out) |
| 78 | } |
| 79 | if st, out := postRaw(t, u, map[string]string{"manifest_digest": plan.ManifestDigest}, fx.operator); st != http.StatusTooManyRequests || jsonErrorText(t, out) != textTooManyRequests { |
| 80 | t.Fatalf("second request over the budget: %d %s", st, out) |
| 81 | } |
| 82 | if _, err := release.ReadPromotionRecord(context.Background(), fx.store, fx.pub, "bab", "km", "v1"); !errors.Is(err, release.ErrNoPromotionRecord) { |
| 83 | t.Fatalf("a refused request wrote a record: %v", err) |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | // authorizeDigest is the digest `carlos releases authorize` would show for |
| 88 | // km's v1, read straight from the store so a refused caller's POST can carry |
| 89 | // the right confirmation. |
| 90 | func authorizeDigest(t *testing.T, fx *digestsFixture) string { |
| 91 | t.Helper() |
| 92 | plan, err := release.PlanAuthorize(context.Background(), fx.store, "bab", "km", "v1") |
| 93 | if err != nil { |
| 94 | t.Fatal(err) |
| 95 | } |
| 96 | return plan.ManifestDigest |
| 97 | } |
| 98 | |
| 99 | // requireAuthorizeRefused: the caller gets the system-operator gate's bare |
| 100 | // 404 on the GET and on a POST carrying the right digest, and no record is |
| 101 | // written. |
| 102 | func requireAuthorizeRefused(t *testing.T, fx *digestsFixture, bearer, who string) { |
| 103 | t.Helper() |
| 104 | u := fx.ts.URL + "/api/cli/apps/bab/km/releases/v1/authorize" |
| 105 | if st, out := getRaw(t, u, bearer); st != http.StatusNotFound || strings.TrimSpace(out) != "404 page not found" { |
| 106 | t.Fatalf("%s read the authorize plan: %d %s", who, st, out) |
| 107 | } |
| 108 | if st, out := postRaw(t, u, map[string]string{"manifest_digest": authorizeDigest(t, fx)}, bearer); st != http.StatusNotFound || strings.TrimSpace(out) != "404 page not found" { |
| 109 | t.Fatalf("%s signed an authorization: %d %s", who, st, out) |
| 110 | } |
| 111 | if _, err := release.ReadPromotionRecord(context.Background(), fx.store, fx.pub, "bab", "km", "v1"); !errors.Is(err, release.ErrNoPromotionRecord) { |
| 112 | t.Fatalf("a refused authorize wrote a record: %v", err) |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | // requireAuthorizeSigns: the caller reads the plan and signs it, and the |
| 117 | // record is an authorization of that digest. |
| 118 | func requireAuthorizeSigns(t *testing.T, fx *digestsFixture, bearer, who string) { |
| 119 | t.Helper() |
| 120 | u := fx.ts.URL + "/api/cli/apps/bab/km/releases/v1/authorize" |
| 121 | st, out := getRaw(t, u, bearer) |
| 122 | if st != http.StatusOK { |
| 123 | t.Fatalf("%s: plan: %d %s", who, st, out) |
| 124 | } |
| 125 | var plan struct { |
| 126 | ManifestDigest string `json:"manifest_digest"` |
| 127 | } |
| 128 | if err := json.Unmarshal([]byte(out), &plan); err != nil || plan.ManifestDigest == "" { |
| 129 | t.Fatalf("%s: plan body %s: %v", who, out, err) |
| 130 | } |
| 131 | if st, out := postRaw(t, u, map[string]string{"manifest_digest": plan.ManifestDigest}, bearer); st != http.StatusOK { |
| 132 | t.Fatalf("%s: sign: %d %s", who, st, out) |
| 133 | } |
| 134 | rec, err := release.ReadPromotionRecord(context.Background(), fx.store, fx.pub, "bab", "km", "v1") |
| 135 | if err != nil || !rec.Authorized || rec.ManifestDigest != plan.ManifestDigest { |
| 136 | t.Fatalf("%s: record = %+v, %v", who, rec, err) |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | // TestAuthorizeDoorIsNotAMembersDoor (R19): alice is a member of bab, the |
| 141 | // app's account, but owns nothing in the ops account and is not on |
| 142 | // SystemOperators. An authorize record makes a shipped build installable on |
| 143 | // every channel, so she gets the system gate's bare 404 and writes nothing. |
| 144 | func TestAuthorizeDoorIsNotAMembersDoor(t *testing.T) { |
| 145 | fx := newDigestsFixture(t) |
| 146 | if err := fx.store.Delete(context.Background(), release.PromotionRecordKey("bab", "km", "v1")); err != nil { |
| 147 | t.Fatal(err) |
| 148 | } |
| 149 | requireAuthorizeRefused(t, fx, fx.member, "a plain member") |
| 150 | } |
| 151 | |
| 152 | // TestAuthorizeDoorOpensToSystemOperators (R19): an address on |
| 153 | // SystemOperators that is a member of bab but no ops owner can read and |
| 154 | // sign; the same address with SystemOperators empty is a plain member again. |
| 155 | func TestAuthorizeDoorOpensToSystemOperators(t *testing.T) { |
| 156 | fx := newDigestsFixture(t) |
| 157 | if err := fx.store.Delete(context.Background(), release.PromotionRecordKey("bab", "km", "v1")); err != nil { |
| 158 | t.Fatal(err) |
| 159 | } |
| 160 | fx.srv.cfg.SystemOperators = nil |
| 161 | requireAuthorizeRefused(t, fx, fx.member, "alice off SystemOperators") |
| 162 | fx.srv.cfg.SystemOperators = []string{"alice@keymail.dev"} |
| 163 | requireAuthorizeSigns(t, fx, fx.member, "alice on SystemOperators") |
| 164 | } |
| 165 | |