carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package claimactivation
2
3import (
4 "bytes"
5 "crypto/ed25519"
6 "crypto/rand"
7 "crypto/sha256"
8 "encoding/base64"
9 "encoding/hex"
10 "encoding/json"
11 "net/http"
12 "net/http/httptest"
13 "sort"
14 "strings"
15 "sync"
16 "testing"
17
18 "github.com/carlosframework/platform/internal/hostactors"
19 "github.com/carlosframework/platform/internal/hostfloor"
20 "github.com/carlosframework/platform/internal/lease/leasetest"
21 "github.com/carlosframework/platform/internal/release"
22)
23
24const artifactsBucket = "carlos-trial-bucket"
25
26// s3Seen is one request the fake bucket answered.
27type s3Seen struct{ Method, Key, IfNoneMatch, IfMatch string }
28
29// artifactsWorld is the trusted signing host against a real S3Store: the
30// production client over leasetest's S3 server, which honours If-None-Match
31// and If-Match. The roster is initialized and configured through the
32// runner's own phases, and the console release is shipped and promoted
33// through release.Ship and Signer.Promote with pointer digests on, as
34// carlos ship and carlos promote leave it.
35type artifactsWorld struct {
36 r Runner
37 store release.Store
38 model *leasetest.VersionedBucket
39 sumsKey ed25519.PrivateKey
40 mu sync.Mutex
41 seen []s3Seen
42 console []byte
43 router []byte
44}
45
46func (w *artifactsWorld) requests() []s3Seen {
47 w.mu.Lock()
48 defer w.mu.Unlock()
49 return append([]s3Seen(nil), w.seen...)
50}
51
52// mark is a point in the request log; writesSince lists every request
53// after it that could change the bucket.
54func (w *artifactsWorld) mark() int { return len(w.requests()) }
55
56func (w *artifactsWorld) writesSince(mark int) []s3Seen {
57 var out []s3Seen
58 for _, r := range w.requests()[mark:] {
59 if r.Method != http.MethodGet && r.Method != http.MethodHead {
60 out = append(out, r)
61 }
62 }
63 return out
64}
65
66// fakeConsoleBuild stands in for release.ArtifactBuild over the fixture's
67// console bytes, which are not Go binaries: "console build <v> stamp <tag>"
68// is a build stamped <tag>. ArtifactBuild itself is tested on real binaries
69// in internal/release, and the root-runner witness runs the production
70// reader over a real console image.
71func fakeConsoleBuild(b []byte) string {
72 _, stamp, ok := strings.Cut(string(b), " stamp ")
73 if !ok {
74 return ""
75 }
76 return stamp
77}
78
79func sha(b []byte) string { s := sha256.Sum256(b); return hex.EncodeToString(s[:]) }
80
81func newArtifactsWorld(t *testing.T) *artifactsWorld {
82 t.Helper()
83 w := &artifactsWorld{model: leasetest.NewVersionedBucket(), console: []byte("console build abc1234 stamp v0.28.2"), router: []byte("carlos v0.28.2 linux-arm64")}
84 w.model.AddBucket(artifactsBucket)
85 next, _ := leasetest.NewS3Server(w.model)
86 server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
87 _, key, _ := strings.Cut(strings.TrimPrefix(req.URL.Path, "/"), "/")
88 w.mu.Lock()
89 w.seen = append(w.seen, s3Seen{req.Method, key, req.Header.Get("If-None-Match"), req.Header.Get("If-Match")})
90 w.mu.Unlock()
91 next.ServeHTTP(rw, req)
92 }))
93 t.Cleanup(server.Close)
94 for k, v := range map[string]string{
95 "AWS_ACCESS_KEY_ID": "test-key", "AWS_SECRET_ACCESS_KEY": "test-secret", "AWS_SESSION_TOKEN": "",
96 "AWS_REGION": "eu-west-1", "AWS_EC2_METADATA_DISABLED": "true",
97 "AWS_CONFIG_FILE": "/nonexistent", "AWS_SHARED_CREDENTIALS_FILE": "/nonexistent",
98 "CARLOS_S3_ENDPOINT": server.URL,
99 } {
100 t.Setenv(k, v)
101 }
102 s3, err := release.NewS3StoreIn(t.Context(), artifactsBucket, "eu-west-1")
103 if err != nil {
104 t.Fatal(err)
105 }
106 w.store = s3
107 pub, key, _ := ed25519.GenerateKey(rand.Reader)
108 sumsPub, sumsKey, _ := ed25519.GenerateKey(rand.Reader)
109 w.sumsKey = sumsKey
110 w.r = Runner{Directory: hostactors.Directory{Store: s3, PublicKey: pub, Bucket: artifactsBucket}, Signer: release.RootSigner(key), SumsKey: base64.StdEncoding.EncodeToString(sumsPub), ConsoleBuild: fakeConsoleBuild}
111 ctx := t.Context()
112 if _, err = w.r.Run(ctx, Plan{Phase: "initialize", Bucket: artifactsBucket}, false); err != nil {
113 t.Fatal(err)
114 }
115 if _, err = w.r.Run(ctx, Plan{Phase: "configure", Bucket: artifactsBucket, ExpectedRevision: 1, Regions: map[string]hostactors.Region{"ie": {Account: "123456789012", AWSRegion: "eu-west-1", Namespaces: []string{"platform"}, Pools: []string{"console", "cohort1"}}}}, false); err != nil {
116 t.Fatal(err)
117 }
118 if err = release.WriteDigestMarker(ctx, s3, w.r.Signer, "bab", "test"); err != nil {
119 t.Fatal(err)
120 }
121 w.ship(t, "abc1234", w.console, "")
122 if err = w.r.Signer.Promote(ctx, s3, "bab", "console", "canary/console", "abc1234"); err != nil {
123 t.Fatal(err)
124 }
125 return w
126}
127
128func (w *artifactsWorld) ship(t *testing.T, version string, body []byte, program string) {
129 t.Helper()
130 m := release.Manifest{App: "console", Version: version, Kind: "binary", Program: program, Artifacts: map[string]release.Artifact{IdentityPlatform: {}}}
131 if err := release.Ship(t.Context(), w.store, "bab", m, map[string][]byte{IdentityPlatform: body}); err != nil {
132 t.Fatal(err)
133 }
134}
135
136func (w *artifactsWorld) revision(t *testing.T) uint64 {
137 t.Helper()
138 o, err := w.r.Directory.Read(t.Context())
139 if err != nil {
140 t.Fatal(err)
141 }
142 return o.Roster.Revision
143}
144
145// sums is a release's SHA256SUMS.txt and its .sig, signed by key, one line
146// per file in name order, as the release recipe writes it.
147func sums(key ed25519.PrivateKey, files map[string][]byte) (string, string) {
148 names := make([]string, 0, len(files))
149 for name := range files {
150 names = append(names, name)
151 }
152 sort.Strings(names)
153 var b strings.Builder
154 for _, name := range names {
155 b.WriteString(sha(files[name]) + " " + name + "\n")
156 }
157 return b.String(), base64.StdEncoding.EncodeToString(ed25519.Sign(key, []byte(b.String()))) + "\n"
158}
159
160// releaseFiles is a release's eight signed files, real-shaped: six carlos
161// builds (Windows zipped) and the two debs, for version X.Y.Z.
162func releaseFiles(version string, router []byte) map[string][]byte {
163 files := map[string][]byte{"carlos-linux-arm64": router}
164 for _, name := range []string{"carlos-darwin-amd64", "carlos-darwin-arm64", "carlos-linux-amd64", "carlos-windows-amd64.zip", "carlos-windows-arm64.zip", "carlos_" + version + "_amd64.deb", "carlos_" + version + "_arm64.deb"} {
165 files[name] = []byte(name + " " + version)
166 }
167 return files
168}
169
170func (w *artifactsWorld) identityPlan(t *testing.T, version string) Plan {
171 text, sig := sums(w.sumsKey, releaseFiles("0.28.2", w.router))
172 return Plan{Phase: "publish-identity", Bucket: artifactsBucket, ExpectedRevision: w.revision(t), Identity: &IdentityInput{
173 ReleaseTag: "v0.28.2", MovesProtocol: 1, Sums: text, SumsSignature: sig,
174 Console: ConsoleRelease{Account: "bab", App: "console", Channel: "canary/console", Version: version}}}
175}
176
177func TestSignPolicyWritesNothingAndSignsTheFloor(t *testing.T) {
178 w := newArtifactsWorld(t)
179 ctx := t.Context()
180 floor := hostfloor.Floor{Min: 1, MinRelease: "v0.28.2"}
181 p := Plan{Phase: "sign-policy", Bucket: artifactsBucket, ExpectedRevision: w.revision(t), Floor: &floor}
182 mark := w.mark()
183 preview, err := w.r.Run(ctx, p, true)
184 if err != nil {
185 t.Fatal(err)
186 }
187 if !preview.DryRun || len(preview.Writes) != 0 || preview.Output != nil || preview.OutputSHA256 != "" || len(preview.FloorSHA256) != 64 {
188 t.Fatalf("preview %+v", preview)
189 }
190 result, err := w.r.Run(ctx, p, false)
191 if err != nil {
192 t.Fatal(err)
193 }
194 if len(result.Writes) != 0 || result.FloorSHA256 != preview.FloorSHA256 || result.OutputSHA256 != hostfloor.Digest(result.Output) {
195 t.Fatalf("result %+v", result)
196 }
197 if got := w.writesSince(mark); len(got) != 0 {
198 t.Fatalf("sign-policy wrote to the bucket: %+v", got)
199 }
200 // The output is the file guard-arm reads: its parser, unmarshal then
201 // Policy.Verify against the deployment key and bucket.
202 var policy hostfloor.Policy
203 if err = json.Unmarshal(result.Output, &policy); err != nil || policy.Mode != "armed" {
204 t.Fatal("not an armed policy", err)
205 }
206 verified, err := policy.Verify(w.r.Directory.PublicKey, artifactsBucket)
207 if err != nil || verified.Floor != floor || verified.Digest != result.FloorSHA256 {
208 t.Fatal(verified, err)
209 }
210 // The JSON the command prints never carries the policy.
211 printed, _ := json.Marshal(result)
212 if bytes.Contains(printed, []byte("authorization")) {
213 t.Fatalf("policy embedded in the printed result: %s", printed)
214 }
215
216 // Once the deployment is bound to a floor, only that floor is signed.
217 if err = hostfloor.Publish(ctx, w.store, w.r.Signer, artifactsBucket, floor); err != nil {
218 t.Fatal(err)
219 }
220 again, err := w.r.Run(ctx, p, false)
221 if err != nil || !bytes.Equal(again.Output, result.Output) {
222 t.Fatal("signing the published floor again", err)
223 }
224 other := hostfloor.Floor{Min: 1, MinRelease: "v0.28.3"}
225 p.Floor = &other
226 if _, err = w.r.Run(ctx, p, false); err == nil {
227 t.Fatal("signed a floor other than the published one")
228 }
229}
230
231func TestSignPolicyRefusesAHalfPublishedOrOtherFloor(t *testing.T) {
232 floor := hostfloor.Floor{Min: 1, MinRelease: "v0.28.2"}
233 t.Run("authorization alone", func(t *testing.T) {
234 w := newArtifactsWorld(t)
235 _, auth, err := hostfloor.Authorize(w.r.Signer, artifactsBucket, floor)
236 if err != nil {
237 t.Fatal(err)
238 }
239 if _, err = w.store.Put(t.Context(), hostfloor.AuthKey, auth, ""); err != nil {
240 t.Fatal(err)
241 }
242 if _, err = w.r.Run(t.Context(), Plan{Phase: "sign-policy", Bucket: artifactsBucket, ExpectedRevision: w.revision(t), Floor: &floor}, false); err == nil {
243 t.Fatal("signed beside half a floor pair")
244 }
245 })
246 t.Run("roster prepared for another floor", func(t *testing.T) {
247 w := newArtifactsWorld(t)
248 ctx := t.Context()
249 o, _ := w.r.Directory.Read(ctx)
250 next := hostactors.Clone(o.Roster)
251 next.Publication = "preparing"
252 next.FloorSHA256 = strings.Repeat("e", 64)
253 if _, err := w.r.Directory.Replace(ctx, w.r.Signer, o, next); err != nil {
254 t.Fatal(err)
255 }
256 if _, err := w.r.Run(ctx, Plan{Phase: "sign-policy", Bucket: artifactsBucket, ExpectedRevision: w.revision(t), Floor: &floor}, false); err == nil {
257 t.Fatal("signed a floor the roster is not preparing")
258 }
259 })
260 t.Run("stale revision and bad floors", func(t *testing.T) {
261 w := newArtifactsWorld(t)
262 for _, p := range []Plan{
263 {Phase: "sign-policy", Bucket: artifactsBucket, ExpectedRevision: w.revision(t) - 1, Floor: &floor},
264 {Phase: "sign-policy", Bucket: artifactsBucket, ExpectedRevision: w.revision(t)},
265 {Phase: "sign-policy", Bucket: artifactsBucket, ExpectedRevision: w.revision(t), Floor: &hostfloor.Floor{Min: 0, MinRelease: "v0.28.2"}},
266 {Phase: "sign-policy", Bucket: artifactsBucket, ExpectedRevision: w.revision(t), Floor: &hostfloor.Floor{Min: 1, MinRelease: "26065378"}},
267 } {
268 if _, err := w.r.Run(t.Context(), p, false); err == nil {
269 t.Fatal("accepted", p)
270 }
271 }
272 })
273}
274
275func TestPublishIdentityFromSignedInputsOnly(t *testing.T) {
276 w := newArtifactsWorld(t)
277 ctx := t.Context()
278 p := w.identityPlan(t, "abc1234")
279 before := w.mark()
280 preview, err := w.r.Run(ctx, p, true)
281 if err != nil {
282 t.Fatal(err)
283 }
284 if got := w.writesSince(before); len(got) != 0 {
285 t.Fatalf("dry run wrote: %+v", got)
286 }
287 if len(preview.Writes) != 1 || preview.Writes[0].Key != "control/system/router/releases/v0.28.2.json" || preview.Output != nil {
288 t.Fatalf("preview %+v", preview)
289 }
290 mark := w.mark()
291 result, err := w.r.Run(ctx, p, false)
292 if err != nil {
293 t.Fatal(err)
294 }
295 identityPuts := 0
296 for _, r := range w.requests()[mark:] {
297 switch {
298 case r.Method == http.MethodPut && r.Key == result.IdentityKey:
299 identityPuts++
300 if r.IfNoneMatch != "*" || r.IfMatch != "" {
301 t.Fatalf("identity PUT not create-only: %+v", r)
302 }
303 case r.Method != http.MethodGet && r.Method != http.MethodHead:
304 t.Fatalf("publish-identity sent %s %s", r.Method, r.Key)
305 }
306 }
307 if identityPuts != 1 || len(result.Writes) != 1 {
308 t.Fatal("identity writes", identityPuts, result.Writes)
309 }
310 stored, _, err := w.store.Get(ctx, result.IdentityKey)
311 if err != nil || !bytes.Equal(stored, result.Output) || result.OutputSHA256 != hostfloor.Digest(stored) {
312 t.Fatal("output is not the stored identity", err)
313 }
314 // The file is what guard-arm reads with --identity.
315 got, err := release.VerifySystemIdentity(w.r.Directory.PublicKey, result.Output)
316 if err != nil {
317 t.Fatal(err)
318 }
319 c := got.Current
320 if c.Version != "v0.28.2" || c.ReleaseTag != "v0.28.2" || c.Source != "release:v0.28.2" || c.MovesProtocol != 1 ||
321 c.Artifacts[IdentityPlatform] != (release.SystemArtifact{Key: "bootstrap/router/v0.28.2/carlos-linux-arm64", SHA256: sha(w.router)}) ||
322 c.ConsoleArtifacts[IdentityPlatform] != (release.SystemArtifact{Key: "bootstrap/console/v0.28.2/carlos-linux-arm64", SHA256: sha(w.console)}) ||
323 len(c.Artifacts) != 1 || len(c.ConsoleArtifacts) != 1 {
324 t.Fatalf("identity names %+v", c)
325 }
326 floor := hostfloor.Verified{Floor: hostfloor.Floor{Min: 1, MinRelease: "v0.28.2"}}
327 if hostfloor.CheckImage(w.r.Directory.PublicKey, floor, result.Output, "v0.28.2", IdentityPlatform, sha(w.router)) != nil ||
328 hostfloor.CheckComponent(w.r.Directory.PublicKey, floor, result.Output, "v0.28.2", IdentityPlatform, sha(w.console), "console") != nil {
329 t.Fatal("guard-arm's checks refuse the published identity")
330 }
331
332 // The same plan again is a no-op that hands back the stored bytes.
333 p.ExpectedRevision = w.revision(t)
334 // Signed within the same second the bytes are identical, and the
335 // checked store reports the matching create as the resumed write; later
336 // they differ and PublishSystemIdentity keeps the stored identity.
337 // Either way the stored bytes are what comes back.
338 replay, err := w.r.Run(ctx, p, false)
339 if err != nil || len(replay.Writes) > 1 || !bytes.Equal(replay.Output, stored) {
340 t.Fatal("replay", replay, err)
341 }
342 if now, _, _ := w.store.Get(ctx, result.IdentityKey); !bytes.Equal(now, stored) {
343 t.Fatal("replay replaced the identity")
344 }
345 // Another console build under the same tag is refused, and nothing moves.
346 other := []byte("console build def5678 stamp v0.28.2")
347 w.ship(t, "def5678", other, ConsoleProgram)
348 if err = w.r.Signer.Promote(ctx, w.store, "bab", "console", "canary/console", "def5678"); err != nil {
349 t.Fatal(err)
350 }
351 p = w.identityPlan(t, "def5678")
352 mark = w.mark()
353 if _, err = w.r.Run(ctx, p, false); err == nil {
354 t.Fatal("a different identity replaced the published one")
355 }
356 if now, _, _ := w.store.Get(ctx, result.IdentityKey); !bytes.Equal(now, stored) {
357 t.Fatal("refused publish changed the identity")
358 }
359 for _, r := range w.writesSince(mark) {
360 if r.Key != result.IdentityKey || r.IfNoneMatch != "*" {
361 t.Fatalf("refused publish sent %+v", r)
362 }
363 }
364 // The older build stays selectable by version through the pointer's
365 // signed history digests.
366 p = w.identityPlan(t, "abc1234")
367 if replay, err = w.r.Run(ctx, p, false); err != nil || !bytes.Equal(replay.Output, stored) {
368 t.Fatal("history version", err)
369 }
370}
371
372func TestPublishIdentityRefusesUnsignedOrWrongInputs(t *testing.T) {
373 for name, tc := range map[string]struct {
374 setup func(t *testing.T, w *artifactsWorld)
375 mutate func(w *artifactsWorld, p *Plan)
376 }{
377 "sums signed by another key": {mutate: func(w *artifactsWorld, p *Plan) {
378 _, other, _ := ed25519.GenerateKey(rand.Reader)
379 p.Identity.Sums, p.Identity.SumsSignature = sums(other, releaseFiles("0.28.2", w.router))
380 }},
381 "sums changed after signing": {mutate: func(w *artifactsWorld, p *Plan) {
382 p.Identity.Sums = strings.Replace(p.Identity.Sums, sha(w.router), strings.Repeat("0", 64), 1)
383 }},
384 "no linux-arm64 router": {mutate: func(w *artifactsWorld, p *Plan) {
385 files := releaseFiles("0.28.2", w.router)
386 delete(files, "carlos-linux-arm64")
387 p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, files)
388 }},
389 "console is a release file": {mutate: func(w *artifactsWorld, p *Plan) {
390 files := releaseFiles("0.28.2", w.router)
391 files["carlos-darwin-arm64"] = w.console
392 p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, files)
393 }},
394 // F3: a genuine, validly signed list for another release is not this
395 // tag's, whatever its router hash.
396 "v0.28.1's real-shaped sums under v0.28.2": {mutate: func(w *artifactsWorld, p *Plan) {
397 p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, releaseFiles("0.28.1", w.router))
398 }},
399 "amd64 deb missing": {mutate: func(w *artifactsWorld, p *Plan) {
400 files := releaseFiles("0.28.2", w.router)
401 delete(files, "carlos_0.28.2_amd64.deb")
402 p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, files)
403 }},
404 "arm64 deb missing": {mutate: func(w *artifactsWorld, p *Plan) {
405 files := releaseFiles("0.28.2", w.router)
406 delete(files, "carlos_0.28.2_arm64.deb")
407 p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, files)
408 }},
409 "another release's deb beside": {mutate: func(w *artifactsWorld, p *Plan) {
410 files := releaseFiles("0.28.2", w.router)
411 files["carlos_0.28.1_arm64.deb"] = []byte("old deb")
412 p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, files)
413 }},
414 "debs for 0.28.20": {mutate: func(w *artifactsWorld, p *Plan) {
415 p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, releaseFiles("0.28.20", w.router))
416 }},
417 "no sums key": {mutate: func(w *artifactsWorld, p *Plan) { w.r.SumsKey = "" }},
418 "moves protocol zero": {mutate: func(w *artifactsWorld, p *Plan) { p.Identity.MovesProtocol = 0 }},
419 "build as tag": {mutate: func(w *artifactsWorld, p *Plan) { p.Identity.ReleaseTag = "26065378" }},
420 "version not on the channel": {mutate: func(w *artifactsWorld, p *Plan) {
421 p.Identity.Console.Version = "fff0000"
422 }},
423 "other channel": {mutate: func(w *artifactsWorld, p *Plan) { p.Identity.Console.Channel = "stable/console" }},
424 "other account": {mutate: func(w *artifactsWorld, p *Plan) { p.Identity.Console.Account = "bap" }},
425 "no account": {mutate: func(w *artifactsWorld, p *Plan) { p.Identity.Console.Account = "" }},
426 "pointer without digests": {setup: func(t *testing.T, w *artifactsWorld) {
427 if err := release.RemoveDigestMarker(t.Context(), w.store, "bab"); err != nil {
428 t.Fatal(err)
429 }
430 w.ship(t, "aaa1111", []byte("console build aaa1111 stamp v0.28.2"), "")
431 if err := w.r.Signer.Promote(t.Context(), w.store, "bab", "console", "edge/console", "aaa1111"); err != nil {
432 t.Fatal(err)
433 }
434 // The pointer verifies and names the version; it signs no digest.
435 ptr, err := release.Channel(t.Context(), w.store, w.r.Directory.PublicKey, "bab", "console", "edge/console")
436 if err != nil || ptr.Version != "aaa1111" || ptr.DigestOf("aaa1111") != "" {
437 t.Fatal("fixture pointer", ptr, err)
438 }
439 }, mutate: func(w *artifactsWorld, p *Plan) {
440 p.Identity.Console.Channel, p.Identity.Console.Version = "edge/console", "aaa1111"
441 }},
442 "manifest swapped after promotion": {setup: func(t *testing.T, w *artifactsWorld) {
443 key := release.ManifestKey("bab", "console", "abc1234")
444 raw, _, err := w.store.Get(t.Context(), key)
445 if err != nil {
446 t.Fatal(err)
447 }
448 var m release.Manifest
449 _ = json.Unmarshal(raw, &m)
450 a := m.Artifacts[IdentityPlatform]
451 a.SHA256 = strings.Repeat("d", 64)
452 m.Artifacts[IdentityPlatform] = a
453 swapped, _ := json.Marshal(m)
454 w.model.Put(artifactsBucket, key, swapped)
455 }},
456 "console stamped with its build sha": {setup: func(t *testing.T, w *artifactsWorld) {
457 w.ship(t, "ccc3333", []byte("console build ccc3333 stamp ccc3333"), ConsoleProgram)
458 if err := w.r.Signer.Promote(t.Context(), w.store, "bab", "console", "canary/console", "ccc3333"); err != nil {
459 t.Fatal(err)
460 }
461 }, mutate: func(w *artifactsWorld, p *Plan) { p.Identity.Console.Version = "ccc3333" }},
462 "console with no stamp": {setup: func(t *testing.T, w *artifactsWorld) {
463 w.ship(t, "ddd4444", []byte("console build ddd4444"), ConsoleProgram)
464 if err := w.r.Signer.Promote(t.Context(), w.store, "bab", "console", "canary/console", "ddd4444"); err != nil {
465 t.Fatal(err)
466 }
467 }, mutate: func(w *artifactsWorld, p *Plan) { p.Identity.Console.Version = "ddd4444" }},
468 "artefact swapped after promotion": {setup: func(t *testing.T, w *artifactsWorld) {
469 m, err := release.GetManifest(t.Context(), w.store, "bab", "console", "abc1234")
470 if err != nil {
471 t.Fatal(err)
472 }
473 w.model.Put(artifactsBucket, release.ArtifactKey("bab", "console", "abc1234", m.Artifacts[IdentityPlatform].File), []byte("console build evil stamp v0.28.2"))
474 }},
475 "production stamp reader on fixture bytes": {mutate: func(w *artifactsWorld, p *Plan) { w.r.ConsoleBuild = nil }},
476 "the CLI shipped as the console": {setup: func(t *testing.T, w *artifactsWorld) {
477 w.ship(t, "bbb2222", []byte("carlos cli stamp v0.28.2"), "github.com/carlosframework/platform")
478 if err := w.r.Signer.Promote(t.Context(), w.store, "bab", "console", "canary/console", "bbb2222"); err != nil {
479 t.Fatal(err)
480 }
481 }, mutate: func(w *artifactsWorld, p *Plan) { p.Identity.Console.Version = "bbb2222" }},
482 } {
483 t.Run(name, func(t *testing.T) {
484 w := newArtifactsWorld(t)
485 if tc.setup != nil {
486 tc.setup(t, w)
487 }
488 p := w.identityPlan(t, "abc1234")
489 if tc.mutate != nil {
490 tc.mutate(w, &p)
491 }
492 mark := w.mark()
493 for _, dry := range []bool{true, false} {
494 result, err := w.r.Run(t.Context(), p, dry)
495 if err == nil {
496 t.Fatalf("accepted (dry %v): %+v", dry, result)
497 }
498 t.Logf("dry %v refused: %v", dry, err)
499 }
500 if got := w.writesSince(mark); len(got) != 0 {
501 t.Fatalf("refusal wrote: %+v", got)
502 }
503 })
504 }
505}
506