| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "net/http" |
| 8 | "os" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/release" |
| 14 | "github.com/carlosframework/platform/internal/svcrole" |
| 15 | ) |
| 16 | |
| 17 | // The console's pointer digest doors (#253, spec §4.6): `carlos channels |
| 18 | // resign`, `carlos channels digests` and the digest state on `carlos |
| 19 | // channels`. |
| 20 | |
| 21 | func TestResignDoorPlansThenSigns(t *testing.T) { |
| 22 | fx := newDigestsFixture(t) |
| 23 | st, out := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/channels/resign", map[string]string{"channel": "beta"}, fx.member) |
| 24 | if st != http.StatusOK { |
| 25 | t.Fatalf("plan: %d %s", st, out) |
| 26 | } |
| 27 | var plan struct { |
| 28 | Version string `json:"version"` |
| 29 | Digest string `json:"digest"` |
| 30 | History []string `json:"history"` |
| 31 | HistoryDigests []string `json:"history_digests"` |
| 32 | Computed []bool `json:"computed"` |
| 33 | } |
| 34 | if err := json.Unmarshal([]byte(out), &plan); err != nil || plan.Digest == "" { |
| 35 | t.Fatalf("plan body %s: %v", out, err) |
| 36 | } |
| 37 | if plan.Version != "v2" || len(plan.History) != 1 || plan.History[0] != "v1" || len(plan.HistoryDigests) != 1 || |
| 38 | plan.HistoryDigests[0] == "" || len(plan.Computed) != 2 || !plan.Computed[0] || !plan.Computed[1] { |
| 39 | t.Fatalf("plan = %+v, want v2 over v1, both digests read from the bucket now (beta carries no rules, so a member signs it)", plan) |
| 40 | } |
| 41 | if p, _ := release.Channel(context.Background(), fx.store, fx.pub, "bab", "km", "beta"); p.Digest != "" { |
| 42 | t.Fatal("planning signed something") |
| 43 | } |
| 44 | st, out = postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/channels/resign", map[string]any{ |
| 45 | "channel": "beta", "digest": plan.Digest, "history_digests": plan.HistoryDigests}, fx.member) |
| 46 | if st != http.StatusOK || !strings.Contains(out, `"result":"resigned"`) { |
| 47 | t.Fatalf("sign: %d %s", st, out) |
| 48 | } |
| 49 | p, err := release.Channel(context.Background(), fx.store, fx.pub, "bab", "km", "beta") |
| 50 | if err != nil || p.Digest != plan.Digest || len(p.HistoryDigests) != 1 || p.HistoryDigests[0] != plan.HistoryDigests[0] { |
| 51 | t.Fatalf("pointer after resign = %+v, %v; want the plan's digests", p, err) |
| 52 | } |
| 53 | events, err := fx.st.Events(OpsAccountID, "km", 10) |
| 54 | if err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | found := false |
| 58 | for _, e := range events { |
| 59 | if e.Action == "resign" && e.Address == "alice@keymail.dev" && strings.Contains(e.Detail, "(cli)") { |
| 60 | found = true |
| 61 | } |
| 62 | } |
| 63 | if !found { |
| 64 | t.Fatalf("events = %v, want a resign row by the member that names the CLI", events) |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | func TestResignDoorRefusesDigestsThatAreNotThePlan(t *testing.T) { |
| 69 | fx := newDigestsFixture(t) |
| 70 | st, out := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/channels/resign", map[string]any{ |
| 71 | "channel": "beta", "digest": release.ManifestDigest([]byte("not it")), "history_digests": []string{""}}, fx.member) |
| 72 | if st != http.StatusConflict || jsonErrorText(t, out) != fmt.Sprintf(release.TextResignChanged, "beta") { |
| 73 | t.Fatalf("mismatched confirmation: %d %s", st, out) |
| 74 | } |
| 75 | if p, _ := release.Channel(context.Background(), fx.store, fx.pub, "bab", "km", "beta"); p.Digest != "" { |
| 76 | t.Fatal("a refused confirmation signed something") |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | func TestResignDoorRefusesServiceCredentials(t *testing.T) { |
| 81 | fx := newDigestsFixture(t) |
| 82 | st, _ := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/channels/resign", map[string]string{"channel": "stable"}, fx.serviceToken) |
| 83 | if st != http.StatusNotFound && st != http.StatusForbidden { |
| 84 | t.Fatalf("a service credential reached resign: %d", st) |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | // TestResignDoorSpendsTheWriteBudget: resign signs a pointer, so it draws on |
| 89 | // the same per-person write budget as rollback, before any store work. |
| 90 | func TestResignDoorSpendsTheWriteBudget(t *testing.T) { |
| 91 | fx := newDigestsFixture(t) |
| 92 | fx.srv.cli.write = newRateLimiter(1, time.Minute) |
| 93 | body := map[string]string{"channel": "stable"} |
| 94 | if st, out := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/channels/resign", body, fx.member); st != http.StatusOK { |
| 95 | t.Fatalf("first request: %d %s", st, out) |
| 96 | } |
| 97 | if st, out := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/channels/resign", body, fx.member); st != http.StatusTooManyRequests || jsonErrorText(t, out) != textTooManyRequests { |
| 98 | t.Fatalf("second request over the budget: %d %s", st, out) |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | // Astra r1 #8: the digest doors reuse the rollback door's one definition of |
| 103 | // the rate-limit refusal instead of repeating its literal, so their source |
| 104 | // adds no em dash. |
| 105 | func TestDigestDoorsDefineNoEmDashLiteral(t *testing.T) { |
| 106 | src, err := os.ReadFile("apidigests.go") |
| 107 | if err != nil { |
| 108 | t.Fatal(err) |
| 109 | } |
| 110 | if n := strings.Count(string(src), "\u2014"); n != 0 { |
| 111 | t.Fatalf("apidigests.go carries %d em dash(es); reuse textTooManyRequests", n) |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | func TestSystemDigestsDoorTurnsOnAndListsStragglers(t *testing.T) { |
| 116 | fx := newDigestsFixture(t) |
| 117 | ctx := context.Background() |
| 118 | st, out := postRaw(t, fx.ts.URL+"/api/cli/system/pointer-digests", map[string]any{"on": false}, fx.member) |
| 119 | if st != http.StatusNotFound { |
| 120 | t.Fatalf("a member who is not an operator turned digests off: %d %s", st, out) |
| 121 | } |
| 122 | if m, err := release.ReadDigestMarker(ctx, fx.store, fx.pub, "bab"); m == nil || err != nil { |
| 123 | t.Fatalf("the member's refused request moved the marker: %+v, %v", m, err) |
| 124 | } |
| 125 | st, out = postRaw(t, fx.ts.URL+"/api/cli/system/pointer-digests", map[string]any{"on": false}, fx.operator) |
| 126 | if st != http.StatusOK || !strings.Contains(out, `"account":"bab","on":false`) { |
| 127 | t.Fatalf("operator off: %d %s", st, out) |
| 128 | } |
| 129 | if m, err := release.ReadDigestMarker(ctx, fx.store, fx.pub, "bab"); m != nil || err != nil { |
| 130 | t.Fatalf("marker after off = %+v, %v", m, err) |
| 131 | } |
| 132 | st, out = postRaw(t, fx.ts.URL+"/api/cli/system/pointer-digests", map[string]any{"on": true, "accounts": []string{"bab"}}, fx.operator) |
| 133 | if st != http.StatusOK || !strings.Contains(out, `"account":"bab","on":true`) { |
| 134 | t.Fatalf("operator on: %d %s", st, out) |
| 135 | } |
| 136 | if m, err := release.ReadDigestMarker(ctx, fx.store, fx.pub, "bab"); m == nil || err != nil { |
| 137 | t.Fatalf("marker = %+v, %v", m, err) |
| 138 | } |
| 139 | st, out = getRaw(t, fx.ts.URL+"/api/cli/system/pointer-digests", fx.operator) |
| 140 | if st != http.StatusOK || !strings.Contains(out, `"account":"bab","app":"km","channel":"stable"`) { |
| 141 | t.Fatalf("stable has no digests yet and must be listed: %d %s", st, out) |
| 142 | } |
| 143 | if st, out := getRaw(t, fx.ts.URL+"/api/cli/system/pointer-digests", fx.member); st != http.StatusNotFound { |
| 144 | t.Fatalf("a member who is not an operator read the report: %d %s", st, out) |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | // TestSystemDigestsDoorListsOnlyHeadsWithoutDigests: once canary/x's head |
| 149 | // carries a digest it leaves the list, and stable stays on it. |
| 150 | func TestSystemDigestsDoorListsOnlyHeadsWithoutDigests(t *testing.T) { |
| 151 | fx := newDigestsFixture(t) |
| 152 | fx.markerThenPromote(t, "v2") |
| 153 | st, out := getRaw(t, fx.ts.URL+"/api/cli/system/pointer-digests", fx.operator) |
| 154 | if st != http.StatusOK { |
| 155 | t.Fatalf("report: %d %s", st, out) |
| 156 | } |
| 157 | var rep struct { |
| 158 | Channels []struct{ Account, App, Channel string } `json:"channels"` |
| 159 | } |
| 160 | if err := json.Unmarshal([]byte(out), &rep); err != nil { |
| 161 | t.Fatalf("report body %s: %v", out, err) |
| 162 | } |
| 163 | listed := map[string]bool{} |
| 164 | for _, c := range rep.Channels { |
| 165 | listed[c.Account+"/"+c.App+"/"+c.Channel] = true |
| 166 | } |
| 167 | if !listed["bab/km/stable"] || listed["bab/km/canary/x"] { |
| 168 | t.Fatalf("listed = %v, want km's stable and not its canary/x", listed) |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | // Astra r1 #4: a channel whose pointer cannot be verified is not skipped as |
| 173 | // if it carried digests. The account's row carries the error, so the CLI |
| 174 | // reports it unknown and never calls the fleet ready. |
| 175 | func TestSystemDigestsDoorReportsAChannelItCannotRead(t *testing.T) { |
| 176 | fx := newDigestsFixture(t) |
| 177 | ctx := context.Background() |
| 178 | key := release.ChannelKey(release.OpsAccount, "km", "beta") |
| 179 | _, etag, err := fx.store.Get(ctx, key) |
| 180 | if err != nil { |
| 181 | t.Fatal(err) |
| 182 | } |
| 183 | if _, err := fx.store.Put(ctx, key, []byte("junk"), etag); err != nil { |
| 184 | t.Fatal(err) |
| 185 | } |
| 186 | st, out := getRaw(t, fx.ts.URL+"/api/cli/system/pointer-digests", fx.operator) |
| 187 | if st != http.StatusOK { |
| 188 | t.Fatalf("report: %d %s", st, out) |
| 189 | } |
| 190 | var rep struct { |
| 191 | Accounts []struct{ Account, Error string } `json:"accounts"` |
| 192 | Channels []struct{ Account, App, Channel string } `json:"channels"` |
| 193 | } |
| 194 | if err := json.Unmarshal([]byte(out), &rep); err != nil { |
| 195 | t.Fatalf("report body %s: %v", out, err) |
| 196 | } |
| 197 | rows, _, err := release.DigestReport(ctx, fx.store, fx.pub, release.OpsAccount, "km") |
| 198 | if err != nil { |
| 199 | t.Fatal(err) |
| 200 | } |
| 201 | want := "" |
| 202 | for _, r := range rows { |
| 203 | if r.Channel == "beta" { |
| 204 | want = r.Err |
| 205 | } |
| 206 | } |
| 207 | if want == "" { |
| 208 | t.Fatal("control: the junk pointer read as a channel state") |
| 209 | } |
| 210 | found := false |
| 211 | for _, a := range rep.Accounts { |
| 212 | if a.Account == release.OpsAccount { |
| 213 | found = true |
| 214 | if a.Error != want { |
| 215 | t.Fatalf("bab's row error = %q, want %q", a.Error, want) |
| 216 | } |
| 217 | } |
| 218 | } |
| 219 | if !found { |
| 220 | t.Fatalf("no row for bab: %s", out) |
| 221 | } |
| 222 | for _, c := range rep.Channels { |
| 223 | if c.Channel == "beta" { |
| 224 | t.Fatalf("the unreadable channel was listed as a state: %s", out) |
| 225 | } |
| 226 | } |
| 227 | } |
| 228 | |
| 229 | func TestChannelsDoorCarriesDigestState(t *testing.T) { |
| 230 | fx := newDigestsFixture(t) |
| 231 | st, out := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/channels", fx.member) |
| 232 | if st != http.StatusOK || !strings.Contains(out, `"digests":false`) || !strings.Contains(out, `"straggler":false`) || |
| 233 | !strings.Contains(out, `"history_missing":1`) || !strings.Contains(out, `"pointer_digests":{"on":true`) { |
| 234 | t.Fatalf("channels: %d %s", st, out) |
| 235 | } |
| 236 | } |
| 237 | |
| 238 | func TestRollbackDoorNamesResign(t *testing.T) { |
| 239 | fx := newDigestsFixture(t) // v1 on canary/x before the marker |
| 240 | fx.markerThenPromote(t, "v2") |
| 241 | st, out := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/rollback", map[string]string{"channel": "canary/x"}, fx.member) |
| 242 | want := (&release.ResignNeededError{App: "km", Channel: "canary/x", Version: "v1"}).Error() |
| 243 | if st != http.StatusConflict || jsonErrorText(t, out) != want { |
| 244 | t.Fatalf("rollback onto a digest-less entry: %d %s, want 409 %q", st, out, want) |
| 245 | } |
| 246 | if cp := fx.channelVersion(t, "km", "canary/x"); cp.Version != "v2" { |
| 247 | t.Fatalf("canary/x after a refused rollback = %q, want v2", cp.Version) |
| 248 | } |
| 249 | } |
| 250 | |
| 251 | // resignPlanOf asks the resign door for channel's plan, as a member may. |
| 252 | func (fx *digestsFixture) resignPlanOf(t *testing.T, channel string) (digest string, historyDigests []string) { |
| 253 | t.Helper() |
| 254 | st, out := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/channels/resign", map[string]string{"channel": channel}, fx.member) |
| 255 | if st != http.StatusOK { |
| 256 | t.Fatalf("plan %s: %d %s", channel, st, out) |
| 257 | } |
| 258 | var plan struct { |
| 259 | Digest string `json:"digest"` |
| 260 | HistoryDigests []string `json:"history_digests"` |
| 261 | } |
| 262 | if err := json.Unmarshal([]byte(out), &plan); err != nil || plan.Digest == "" { |
| 263 | t.Fatalf("plan body %s: %v", out, err) |
| 264 | } |
| 265 | return plan.Digest, plan.HistoryDigests |
| 266 | } |
| 267 | |
| 268 | // rollbackRefusal is what the rollback door answers the member for channel: |
| 269 | // the refusal a resign of that channel must repeat. |
| 270 | func (fx *digestsFixture) rollbackRefusal(t *testing.T, channel string) (int, string) { |
| 271 | t.Helper() |
| 272 | st, out := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/rollback", map[string]string{"channel": channel}, fx.member) |
| 273 | if st != http.StatusForbidden { |
| 274 | t.Fatalf("control: the rollback door let the member roll %s back: %d %s", channel, st, out) |
| 275 | } |
| 276 | return st, out |
| 277 | } |
| 278 | |
| 279 | // Astra r1 #2: signing a resign takes the rollback door's authority. A |
| 280 | // member may read stable's plan, but confirming it is refused exactly as a |
| 281 | // rollback of stable is, and the pointer is unchanged. |
| 282 | func TestResignDoorRefusesAMemberOnStable(t *testing.T) { |
| 283 | fx := newDigestsFixture(t) |
| 284 | digest, hd := fx.resignPlanOf(t, "stable") |
| 285 | wantSt, wantBody := fx.rollbackRefusal(t, "stable") |
| 286 | st, out := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/channels/resign", map[string]any{ |
| 287 | "channel": "stable", "digest": digest, "history_digests": hd}, fx.member) |
| 288 | if st != wantSt || out != wantBody { |
| 289 | t.Fatalf("member resign of stable: %d %s, want the rollback door's %d %s", st, out, wantSt, wantBody) |
| 290 | } |
| 291 | if p := fx.channelVersion(t, "km", "stable"); p.Digest != "" || p.Version != "v2" { |
| 292 | t.Fatalf("a refused resign moved stable: %+v", p) |
| 293 | } |
| 294 | } |
| 295 | |
| 296 | // Astra r1 #2's attack: a legacy stable pointer names v2, and v2's manifest |
| 297 | // is replaced in the bucket (pruned and reshipped). The plan offers the new |
| 298 | // bytes' digest; a member confirming it would bind stable's v2 to bytes no |
| 299 | // one released. Refused as rollback is, and the pointer still signs nothing. |
| 300 | func TestResignDoorRefusesALegacyHeadSubstitution(t *testing.T) { |
| 301 | fx := newDigestsFixture(t) |
| 302 | ctx := context.Background() |
| 303 | key := release.ManifestKey(release.OpsAccount, "km", "v2") |
| 304 | orig, etag, err := fx.store.Get(ctx, key) |
| 305 | if err != nil { |
| 306 | t.Fatal(err) |
| 307 | } |
| 308 | var m map[string]any |
| 309 | if err := json.Unmarshal(orig, &m); err != nil { |
| 310 | t.Fatal(err) |
| 311 | } |
| 312 | m["built_at"] = "2026-10-07T00:00:00Z" // other bytes under the same version |
| 313 | swapped, err := json.Marshal(m) |
| 314 | if err != nil { |
| 315 | t.Fatal(err) |
| 316 | } |
| 317 | if _, err := fx.store.Put(ctx, key, swapped, etag); err != nil { |
| 318 | t.Fatal(err) |
| 319 | } |
| 320 | digest, hd := fx.resignPlanOf(t, "stable") |
| 321 | if digest != release.ManifestDigest(swapped) { |
| 322 | t.Fatalf("the plan offers %s, want the substituted manifest's digest", digest) |
| 323 | } |
| 324 | wantSt, wantBody := fx.rollbackRefusal(t, "stable") |
| 325 | st, out := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/channels/resign", map[string]any{ |
| 326 | "channel": "stable", "digest": digest, "history_digests": hd}, fx.member) |
| 327 | if st != wantSt || out != wantBody { |
| 328 | t.Fatalf("member resign of a substituted stable head: %d %s, want %d %s", st, out, wantSt, wantBody) |
| 329 | } |
| 330 | if p := fx.channelVersion(t, "km", "stable"); p.Digest != "" { |
| 331 | t.Fatalf("stable was bound to the substituted bytes: %+v", p) |
| 332 | } |
| 333 | } |
| 334 | |
| 335 | // A pipelined channel's rules govern resign as they govern rollback, asked |
| 336 | // inside the admitted mutation: a passkey rule refuses with the rollback |
| 337 | // door's refusal, and an approvals rule (which rollback turns into a |
| 338 | // proposal; resign has none to make) refuses the same way. The pointer is |
| 339 | // unchanged either way. |
| 340 | func TestResignDoorAsksTheChannelsPipelineRules(t *testing.T) { |
| 341 | for _, rules := range []release.ChannelRules{{Passkey: true}, {PromoteApprovals: 1}} { |
| 342 | fx := newDigestsFixture(t) |
| 343 | digest, hd := fx.resignPlanOf(t, "beta") |
| 344 | setPipeline(t, fx.consoleFixture, "km", release.PipelineChannel{Name: "beta", Rules: release.ChannelRules{Passkey: true}}) |
| 345 | wantSt, wantBody := fx.rollbackRefusal(t, "beta") |
| 346 | setPipeline(t, fx.consoleFixture, "km", release.PipelineChannel{Name: "beta", Rules: rules}) |
| 347 | st, out := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/channels/resign", map[string]any{ |
| 348 | "channel": "beta", "digest": digest, "history_digests": hd}, fx.member) |
| 349 | if st != wantSt || out != wantBody { |
| 350 | t.Fatalf("rules %+v: member resign of beta: %d %s, want the rollback door's %d %s", rules, st, out, wantSt, wantBody) |
| 351 | } |
| 352 | if p := fx.channelVersion(t, "km", "beta"); p.Digest != "" { |
| 353 | t.Fatalf("rules %+v: a refused resign signed beta: %+v", rules, p) |
| 354 | } |
| 355 | } |
| 356 | } |
| 357 | |
| 358 | // jsonErrorText is the "error" of a jsonError body. |
| 359 | func jsonErrorText(t *testing.T, body string) string { |
| 360 | t.Helper() |
| 361 | var e struct { |
| 362 | Error string `json:"error"` |
| 363 | } |
| 364 | if err := json.Unmarshal([]byte(body), &e); err != nil { |
| 365 | t.Fatalf("not a JSON error body: %s: %v", body, err) |
| 366 | } |
| 367 | return e.Error |
| 368 | } |
| 369 | |
| 370 | // digestsFixture is newConsoleFixture with app km in the ops account bab: |
| 371 | // v1 and v2 shipped, promoted v1 then v2 on stable and on beta, and v1 on |
| 372 | // canary/x, all before the account's digest marker went on, so none of them carries a |
| 373 | // digest. Then the marker, written with the root key (resign needs it, Astra |
| 374 | // I3 #4). |
| 375 | type digestsFixture struct { |
| 376 | *consoleFixture |
| 377 | store release.Store |
| 378 | member string // a CLI bearer for alice, a member of bab |
| 379 | operator string // a CLI bearer for paul, an owner of the ops account |
| 380 | serviceToken string // an app-scoped svcrole.Publish credential for km |
| 381 | } |
| 382 | |
| 383 | func newDigestsFixture(t *testing.T) *digestsFixture { |
| 384 | t.Helper() |
| 385 | fx := newConsoleFixture(t) |
| 386 | now := time.Now() |
| 387 | if _, err := fx.st.CreateAppIn(OpsAccountID, "km", "paul@keymail.dev", now); err != nil { |
| 388 | t.Fatal(err) |
| 389 | } |
| 390 | if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil { |
| 391 | t.Fatal(err) |
| 392 | } |
| 393 | shipVersion(t, fx, "km", "v1") |
| 394 | shipVersion(t, fx, "km", "v2") |
| 395 | seedChannel(t, fx, "km", "stable", "v1") |
| 396 | seedChannel(t, fx, "km", "stable", "v2") |
| 397 | seedChannel(t, fx, "km", "canary/x", "v1") |
| 398 | seedChannel(t, fx, "km", "beta", "v1") |
| 399 | seedChannel(t, fx, "km", "beta", "v2") |
| 400 | dx := &digestsFixture{ |
| 401 | consoleFixture: fx, |
| 402 | store: fx.rel, |
| 403 | member: cliToken(t, fx, "alice@keymail.dev", "code-digests-member"), |
| 404 | operator: cliToken(t, fx, "paul@keymail.dev", "code-digests-operator"), |
| 405 | serviceToken: serviceToken(t, fx.st, OpsAccountID, "digests-ci", "km", svcrole.Publish), |
| 406 | } |
| 407 | dx.writeMarker(t) |
| 408 | return dx |
| 409 | } |
| 410 | |
| 411 | func (fx *digestsFixture) writeMarker(t *testing.T) { |
| 412 | t.Helper() |
| 413 | if err := release.WriteDigestMarker(context.Background(), fx.store, release.RootSigner(fx.priv), release.OpsAccount, "paul@keymail.dev"); err != nil { |
| 414 | t.Fatal(err) |
| 415 | } |
| 416 | } |
| 417 | |
| 418 | // markerThenPromote writes the marker (a no-op when it already verifies) and |
| 419 | // promotes v to canary/x, which therefore carries v's digest. |
| 420 | func (fx *digestsFixture) markerThenPromote(t *testing.T, v string) { |
| 421 | t.Helper() |
| 422 | fx.writeMarker(t) |
| 423 | seedChannel(t, fx.consoleFixture, "km", "canary/x", v) |
| 424 | if cp := fx.channelVersion(t, "km", "canary/x"); cp.Digest == "" { |
| 425 | t.Fatalf("canary/x promoted to %s with the marker on carries no digest", v) |
| 426 | } |
| 427 | } |
| 428 | |